mirror of
https://github.com/langgenius/dify.git
synced 2026-02-20 10:05:55 +08:00
Compare commits
69 Commits
deploy/end
...
feat/memor
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e037d14d1 | |||
| 08ca4e3e8b | |||
| 0998fab282 | |||
| eb536956ef | |||
| 338e10424c | |||
| c2ce2be102 | |||
| b7311bf1d3 | |||
| 4050ed8c8e | |||
| 2f0076166a | |||
| 896a29b836 | |||
| 7cef0fff89 | |||
| 9d310fed92 | |||
| 2944f831e2 | |||
| 10f5270320 | |||
| 38d27100ac | |||
| 9efbfdda55 | |||
| 7e2d16a518 | |||
| 6b2d460023 | |||
| 1cf7007fb4 | |||
| 828b9c6a93 | |||
| c8188274a2 | |||
| d1d419e2b0 | |||
| e90086c2d2 | |||
| 1c3ff179f8 | |||
| 8a348615bf | |||
| 2a27d81553 | |||
| 6b6ab5e034 | |||
| 50a7d93ddc | |||
| 61e4bc6b17 | |||
| 0e545d7be5 | |||
| eddc39cd0a | |||
| efcaa2bbbd | |||
| 05c05bb6d0 | |||
| 57624979e4 | |||
| 7e9375ce7e | |||
| 0b1445aed5 | |||
| f6623423dd | |||
| 77b7bf9d47 | |||
| b0dd7d303d | |||
| f8097b20c8 | |||
| 257e43e2c2 | |||
| 55f146f527 | |||
| 2cd9d1066f | |||
| 612112c919 | |||
| df502e0d79 | |||
| 27d6fee1ed | |||
| 35c116906e | |||
| e9685a38cb | |||
| a7156244f7 | |||
| 01f0ee339e | |||
| 158da1ce6e | |||
| 761ad336e9 | |||
| 239e15eac6 | |||
| 2acee44071 | |||
| 944b2ff22d | |||
| a5dfc09e90 | |||
| 8266dc1dcc | |||
| edeea0a8b9 | |||
| dfc17f3b08 | |||
| bca0b7c087 | |||
| 985becbc41 | |||
| c90ab89323 | |||
| 8547d4b1ff | |||
| fbcfebbba1 | |||
| f827f8dc63 | |||
| fef9907af3 | |||
| e3b7f8afdd | |||
| dceac39078 | |||
| 58fd2f7a2f |
28
.github/workflows/deploy-end-user-dev.yml
vendored
28
.github/workflows/deploy-end-user-dev.yml
vendored
@ -1,28 +0,0 @@
|
||||
name: Deploy Trigger Dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Push API & Web"]
|
||||
branches:
|
||||
- "deploy/end-user-oauth"
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'deploy/end-user-oauth'
|
||||
steps:
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@v0.1.8
|
||||
with:
|
||||
host: ${{ secrets.TRIGGER_SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}
|
||||
@ -1,8 +1,6 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from opentelemetry.trace import get_current_span
|
||||
|
||||
from configs import dify_config
|
||||
from contexts.wrapper import RecyclableContextVar
|
||||
from dify_app import DifyApp
|
||||
@ -28,25 +26,8 @@ def create_flask_app_with_configs() -> DifyApp:
|
||||
# add an unique identifier to each request
|
||||
RecyclableContextVar.increment_thread_recycles()
|
||||
|
||||
# add after request hook for injecting X-Trace-Id header from OpenTelemetry span context
|
||||
@dify_app.after_request
|
||||
def add_trace_id_header(response):
|
||||
try:
|
||||
span = get_current_span()
|
||||
ctx = span.get_span_context() if span else None
|
||||
if ctx and ctx.is_valid:
|
||||
trace_id_hex = format(ctx.trace_id, "032x")
|
||||
# Avoid duplicates if some middleware added it
|
||||
if "X-Trace-Id" not in response.headers:
|
||||
response.headers["X-Trace-Id"] = trace_id_hex
|
||||
except Exception:
|
||||
# Never break the response due to tracing header injection
|
||||
logger.warning("Failed to add trace ID to response header", exc_info=True)
|
||||
return response
|
||||
|
||||
# Capture the decorator's return value to avoid pyright reportUnusedFunction
|
||||
_ = before_request
|
||||
_ = add_trace_id_header
|
||||
|
||||
return dify_app
|
||||
|
||||
|
||||
@ -553,10 +553,7 @@ class LoggingConfig(BaseSettings):
|
||||
|
||||
LOG_FORMAT: str = Field(
|
||||
description="Format string for log messages",
|
||||
default=(
|
||||
"%(asctime)s.%(msecs)03d %(levelname)s [%(threadName)s] "
|
||||
"[%(filename)s:%(lineno)d] %(trace_id)s - %(message)s"
|
||||
),
|
||||
default="%(asctime)s.%(msecs)03d %(levelname)s [%(threadName)s] [%(filename)s:%(lineno)d] - %(message)s",
|
||||
)
|
||||
|
||||
LOG_DATEFORMAT: str | None = Field(
|
||||
|
||||
@ -324,13 +324,10 @@ class AppListApi(Resource):
|
||||
NodeType.TRIGGER_PLUGIN,
|
||||
}
|
||||
for workflow in draft_workflows:
|
||||
try:
|
||||
for _, node_data in workflow.walk_nodes():
|
||||
if node_data.get("type") in trigger_node_types:
|
||||
draft_trigger_app_ids.add(str(workflow.app_id))
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
for _, node_data in workflow.walk_nodes():
|
||||
if node_data.get("type") in trigger_node_types:
|
||||
draft_trigger_app_ids.add(str(workflow.app_id))
|
||||
break
|
||||
|
||||
for app in app_pagination.items:
|
||||
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
|
||||
|
||||
@ -49,6 +49,7 @@ class CompletionConversationQuery(BaseConversationQuery):
|
||||
|
||||
|
||||
class ChatConversationQuery(BaseConversationQuery):
|
||||
message_count_gte: int | None = Field(default=None, ge=1, description="Minimum message count")
|
||||
sort_by: Literal["created_at", "-created_at", "updated_at", "-updated_at"] = Field(
|
||||
default="-updated_at", description="Sort field and direction"
|
||||
)
|
||||
@ -508,6 +509,14 @@ class ChatConversationApi(Resource):
|
||||
.having(func.count(MessageAnnotation.id) == 0)
|
||||
)
|
||||
|
||||
if args.message_count_gte and args.message_count_gte >= 1:
|
||||
query = (
|
||||
query.options(joinedload(Conversation.messages)) # type: ignore
|
||||
.join(Message, Message.conversation_id == Conversation.id)
|
||||
.group_by(Conversation.id)
|
||||
.having(func.count(Message.id) >= args.message_count_gte)
|
||||
)
|
||||
|
||||
if app_model.mode == AppMode.ADVANCED_CHAT:
|
||||
query = query.where(Conversation.invoke_from != InvokeFrom.DEBUGGER)
|
||||
|
||||
|
||||
@ -316,16 +316,18 @@ def validate_and_get_api_token(scope: str | None = None):
|
||||
ApiToken.type == scope,
|
||||
)
|
||||
.values(last_used_at=current_time)
|
||||
.returning(ApiToken)
|
||||
)
|
||||
stmt = select(ApiToken).where(ApiToken.token == auth_token, ApiToken.type == scope)
|
||||
result = session.execute(update_stmt)
|
||||
api_token = session.scalar(stmt)
|
||||
|
||||
if hasattr(result, "rowcount") and result.rowcount > 0:
|
||||
session.commit()
|
||||
api_token = result.scalar_one_or_none()
|
||||
|
||||
if not api_token:
|
||||
raise Unauthorized("Access token is invalid")
|
||||
stmt = select(ApiToken).where(ApiToken.token == auth_token, ApiToken.type == scope)
|
||||
api_token = session.scalar(stmt)
|
||||
if not api_token:
|
||||
raise Unauthorized("Access token is invalid")
|
||||
else:
|
||||
session.commit()
|
||||
|
||||
return api_token
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
@ -56,7 +55,6 @@ from models import Account, EndUser
|
||||
from services.variable_truncator import BaseTruncator, DummyVariableTruncator, VariableTruncator
|
||||
|
||||
NodeExecutionId = NewType("NodeExecutionId", str)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@ -291,30 +289,26 @@ class WorkflowResponseConverter:
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
if event.node_type == NodeType.TOOL:
|
||||
response.data.extras["icon"] = ToolManager.get_tool_icon(
|
||||
tenant_id=self._application_generate_entity.app_config.tenant_id,
|
||||
provider_type=ToolProviderType(event.provider_type),
|
||||
provider_id=event.provider_id,
|
||||
)
|
||||
elif event.node_type == NodeType.DATASOURCE:
|
||||
manager = PluginDatasourceManager()
|
||||
provider_entity = manager.fetch_datasource_provider(
|
||||
self._application_generate_entity.app_config.tenant_id,
|
||||
event.provider_id,
|
||||
)
|
||||
response.data.extras["icon"] = provider_entity.declaration.identity.generate_datasource_icon_url(
|
||||
self._application_generate_entity.app_config.tenant_id
|
||||
)
|
||||
elif event.node_type == NodeType.TRIGGER_PLUGIN:
|
||||
response.data.extras["icon"] = TriggerManager.get_trigger_plugin_icon(
|
||||
self._application_generate_entity.app_config.tenant_id,
|
||||
event.provider_id,
|
||||
)
|
||||
except Exception:
|
||||
# metadata fetch may fail, for example, the plugin daemon is down or plugin is uninstalled.
|
||||
logger.warning("failed to fetch icon for %s", event.provider_id)
|
||||
if event.node_type == NodeType.TOOL:
|
||||
response.data.extras["icon"] = ToolManager.get_tool_icon(
|
||||
tenant_id=self._application_generate_entity.app_config.tenant_id,
|
||||
provider_type=ToolProviderType(event.provider_type),
|
||||
provider_id=event.provider_id,
|
||||
)
|
||||
elif event.node_type == NodeType.DATASOURCE:
|
||||
manager = PluginDatasourceManager()
|
||||
provider_entity = manager.fetch_datasource_provider(
|
||||
self._application_generate_entity.app_config.tenant_id,
|
||||
event.provider_id,
|
||||
)
|
||||
response.data.extras["icon"] = provider_entity.declaration.identity.generate_datasource_icon_url(
|
||||
self._application_generate_entity.app_config.tenant_id
|
||||
)
|
||||
elif event.node_type == NodeType.TRIGGER_PLUGIN:
|
||||
response.data.extras["icon"] = TriggerManager.get_trigger_plugin_icon(
|
||||
self._application_generate_entity.app_config.tenant_id,
|
||||
event.provider_id,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@ -29,7 +29,6 @@ class SimpleModelProviderEntity(BaseModel):
|
||||
provider: str
|
||||
label: I18nObject
|
||||
icon_small: I18nObject | None = None
|
||||
icon_small_dark: I18nObject | None = None
|
||||
icon_large: I18nObject | None = None
|
||||
supported_model_types: list[ModelType]
|
||||
|
||||
@ -43,7 +42,6 @@ class SimpleModelProviderEntity(BaseModel):
|
||||
provider=provider_entity.provider,
|
||||
label=provider_entity.label,
|
||||
icon_small=provider_entity.icon_small,
|
||||
icon_small_dark=provider_entity.icon_small_dark,
|
||||
icon_large=provider_entity.icon_large,
|
||||
supported_model_types=provider_entity.supported_model_types,
|
||||
)
|
||||
|
||||
@ -99,7 +99,6 @@ class SimpleProviderEntity(BaseModel):
|
||||
provider: str
|
||||
label: I18nObject
|
||||
icon_small: I18nObject | None = None
|
||||
icon_small_dark: I18nObject | None = None
|
||||
icon_large: I18nObject | None = None
|
||||
supported_model_types: Sequence[ModelType]
|
||||
models: list[AIModelEntity] = []
|
||||
@ -125,6 +124,7 @@ class ProviderEntity(BaseModel):
|
||||
icon_small: I18nObject | None = None
|
||||
icon_large: I18nObject | None = None
|
||||
icon_small_dark: I18nObject | None = None
|
||||
icon_large_dark: I18nObject | None = None
|
||||
background: str | None = None
|
||||
help: ProviderHelpEntity | None = None
|
||||
supported_model_types: Sequence[ModelType]
|
||||
|
||||
@ -300,14 +300,6 @@ class ModelProviderFactory:
|
||||
file_name = provider_schema.icon_small.zh_Hans
|
||||
else:
|
||||
file_name = provider_schema.icon_small.en_US
|
||||
elif icon_type.lower() == "icon_small_dark":
|
||||
if not provider_schema.icon_small_dark:
|
||||
raise ValueError(f"Provider {provider} does not have small dark icon.")
|
||||
|
||||
if lang.lower() == "zh_hans":
|
||||
file_name = provider_schema.icon_small_dark.zh_Hans
|
||||
else:
|
||||
file_name = provider_schema.icon_small_dark.en_US
|
||||
else:
|
||||
if not provider_schema.icon_large:
|
||||
raise ValueError(f"Provider {provider} does not have large icon.")
|
||||
|
||||
@ -123,16 +123,6 @@ class ApiProviderAuthType(StrEnum):
|
||||
raise ValueError(f"invalid mode value '{value}', expected one of: {valid}")
|
||||
|
||||
|
||||
class ToolAuthType(StrEnum):
|
||||
"""
|
||||
Enum class for tool authentication type.
|
||||
Determines whether OAuth credentials are workspace-level or end-user-level.
|
||||
"""
|
||||
|
||||
WORKSPACE = "workspace"
|
||||
END_USER = "end_user"
|
||||
|
||||
|
||||
class ToolInvokeMessage(BaseModel):
|
||||
class TextMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
@ -203,7 +203,7 @@ class WorkflowTool(Tool):
|
||||
Resolve user object in both HTTP and worker contexts.
|
||||
|
||||
In HTTP context: dereference the current_user LocalProxy (can return Account or EndUser).
|
||||
In worker context: load Account(knowledge pipeline) or EndUser(trigger) from database by user_id.
|
||||
In worker context: load Account from database by user_id (only returns Account, never EndUser).
|
||||
|
||||
Returns:
|
||||
Account | EndUser | None: The resolved user object, or None if resolution fails.
|
||||
@ -224,28 +224,24 @@ class WorkflowTool(Tool):
|
||||
logger.warning("Failed to resolve user from request context: %s", e)
|
||||
return None
|
||||
|
||||
def _resolve_user_from_database(self, user_id: str) -> Account | EndUser | None:
|
||||
def _resolve_user_from_database(self, user_id: str) -> Account | None:
|
||||
"""
|
||||
Resolve user from database (worker/Celery context).
|
||||
"""
|
||||
|
||||
user_stmt = select(Account).where(Account.id == user_id)
|
||||
user = db.session.scalar(user_stmt)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
tenant_stmt = select(Tenant).where(Tenant.id == self.runtime.tenant_id)
|
||||
tenant = db.session.scalar(tenant_stmt)
|
||||
if not tenant:
|
||||
return None
|
||||
|
||||
user_stmt = select(Account).where(Account.id == user_id)
|
||||
user = db.session.scalar(user_stmt)
|
||||
if user:
|
||||
user.current_tenant = tenant
|
||||
return user
|
||||
user.current_tenant = tenant
|
||||
|
||||
end_user_stmt = select(EndUser).where(EndUser.id == user_id, EndUser.tenant_id == tenant.id)
|
||||
end_user = db.session.scalar(end_user_stmt)
|
||||
if end_user:
|
||||
return end_user
|
||||
|
||||
return None
|
||||
return user
|
||||
|
||||
def _get_workflow(self, app_id: str, version: str) -> Workflow:
|
||||
"""
|
||||
|
||||
@ -3,7 +3,7 @@ from typing import Any, Literal, Union
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic_core.core_schema import ValidationInfo
|
||||
|
||||
from core.tools.entities.tool_entities import ToolAuthType, ToolProviderType
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from core.workflow.nodes.base.entities import BaseNodeData
|
||||
|
||||
|
||||
@ -16,7 +16,6 @@ class ToolEntity(BaseModel):
|
||||
tool_configurations: dict[str, Any]
|
||||
credential_id: str | None = None
|
||||
plugin_unique_identifier: str | None = None # redundancy
|
||||
auth_type: ToolAuthType = ToolAuthType.WORKSPACE # OAuth authentication level
|
||||
|
||||
@field_validator("tool_configurations", mode="before")
|
||||
@classmethod
|
||||
|
||||
@ -6,7 +6,6 @@ BASE_CORS_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, HEAD
|
||||
SERVICE_API_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, "Authorization")
|
||||
AUTHENTICATED_HEADERS: tuple[str, ...] = (*SERVICE_API_HEADERS, HEADER_NAME_CSRF_TOKEN)
|
||||
FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN)
|
||||
EXPOSED_HEADERS: tuple[str, ...] = ("X-Version", "X-Env", "X-Trace-Id")
|
||||
|
||||
|
||||
def init_app(app: DifyApp):
|
||||
@ -26,7 +25,6 @@ def init_app(app: DifyApp):
|
||||
service_api_bp,
|
||||
allow_headers=list(SERVICE_API_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
expose_headers=list(EXPOSED_HEADERS),
|
||||
)
|
||||
app.register_blueprint(service_api_bp)
|
||||
|
||||
@ -36,7 +34,7 @@ def init_app(app: DifyApp):
|
||||
supports_credentials=True,
|
||||
allow_headers=list(AUTHENTICATED_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
expose_headers=list(EXPOSED_HEADERS),
|
||||
expose_headers=["X-Version", "X-Env"],
|
||||
)
|
||||
app.register_blueprint(web_bp)
|
||||
|
||||
@ -46,7 +44,7 @@ def init_app(app: DifyApp):
|
||||
supports_credentials=True,
|
||||
allow_headers=list(AUTHENTICATED_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
expose_headers=list(EXPOSED_HEADERS),
|
||||
expose_headers=["X-Version", "X-Env"],
|
||||
)
|
||||
app.register_blueprint(console_app_bp)
|
||||
|
||||
@ -54,7 +52,6 @@ def init_app(app: DifyApp):
|
||||
files_bp,
|
||||
allow_headers=list(FILES_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
expose_headers=list(EXPOSED_HEADERS),
|
||||
)
|
||||
app.register_blueprint(files_bp)
|
||||
|
||||
@ -66,6 +63,5 @@ def init_app(app: DifyApp):
|
||||
trigger_bp,
|
||||
allow_headers=["Content-Type", "Authorization", "X-App-Code"],
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH", "HEAD"],
|
||||
expose_headers=list(EXPOSED_HEADERS),
|
||||
)
|
||||
app.register_blueprint(trigger_bp)
|
||||
|
||||
@ -7,7 +7,6 @@ from logging.handlers import RotatingFileHandler
|
||||
import flask
|
||||
|
||||
from configs import dify_config
|
||||
from core.helper.trace_id_helper import get_trace_id_from_otel_context
|
||||
from dify_app import DifyApp
|
||||
|
||||
|
||||
@ -77,9 +76,7 @@ class RequestIdFilter(logging.Filter):
|
||||
# the logging format. Note that we're checking if we're in a request
|
||||
# context, as we may want to log things before Flask is fully loaded.
|
||||
def filter(self, record):
|
||||
trace_id = get_trace_id_from_otel_context() or ""
|
||||
record.req_id = get_request_id() if flask.has_request_context() else ""
|
||||
record.trace_id = trace_id
|
||||
return True
|
||||
|
||||
|
||||
@ -87,8 +84,6 @@ class RequestIdFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
if not hasattr(record, "req_id"):
|
||||
record.req_id = ""
|
||||
if not hasattr(record, "trace_id"):
|
||||
record.trace_id = ""
|
||||
return super().format(record)
|
||||
|
||||
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import flask
|
||||
import werkzeug.http
|
||||
from flask import Flask, g
|
||||
from flask import Flask
|
||||
from flask.signals import request_finished, request_started
|
||||
|
||||
from configs import dify_config
|
||||
from core.helper.trace_id_helper import get_trace_id_from_otel_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -22,9 +20,6 @@ def _is_content_type_json(content_type: str) -> bool:
|
||||
|
||||
def _log_request_started(_sender, **_extra):
|
||||
"""Log the start of a request."""
|
||||
# Record start time for access logging
|
||||
g.__request_started_ts = time.perf_counter()
|
||||
|
||||
if not logger.isEnabledFor(logging.DEBUG):
|
||||
return
|
||||
|
||||
@ -47,39 +42,8 @@ def _log_request_started(_sender, **_extra):
|
||||
|
||||
|
||||
def _log_request_finished(_sender, response, **_extra):
|
||||
"""Log the end of a request.
|
||||
|
||||
Safe to call with or without an active Flask request context.
|
||||
"""
|
||||
if response is None:
|
||||
return
|
||||
|
||||
# Always emit a compact access line at INFO with trace_id so it can be grepped
|
||||
has_ctx = flask.has_request_context()
|
||||
start_ts = getattr(g, "__request_started_ts", None) if has_ctx else None
|
||||
duration_ms = None
|
||||
if start_ts is not None:
|
||||
duration_ms = round((time.perf_counter() - start_ts) * 1000, 3)
|
||||
|
||||
# Request attributes are available only when a request context exists
|
||||
if has_ctx:
|
||||
req_method = flask.request.method
|
||||
req_path = flask.request.path
|
||||
else:
|
||||
req_method = "-"
|
||||
req_path = "-"
|
||||
|
||||
trace_id = get_trace_id_from_otel_context() or response.headers.get("X-Trace-Id") or ""
|
||||
logger.info(
|
||||
"%s %s %s %s %s",
|
||||
req_method,
|
||||
req_path,
|
||||
getattr(response, "status_code", "-"),
|
||||
duration_ms if duration_ms is not None else "-",
|
||||
trace_id,
|
||||
)
|
||||
|
||||
if not logger.isEnabledFor(logging.DEBUG):
|
||||
"""Log the end of a request."""
|
||||
if not logger.isEnabledFor(logging.DEBUG) or response is None:
|
||||
return
|
||||
|
||||
if not _is_content_type_json(response.content_type):
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
"""add enduser authentication provider
|
||||
|
||||
Revision ID: a7b4e8f2c9d1
|
||||
Revises: fecff1c3da27
|
||||
Create Date: 2025-11-18 14:00:00.000000
|
||||
|
||||
"""
|
||||
import models as models
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a7b4e8f2c9d1"
|
||||
down_revision = "fecff1c3da27"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"tool_enduser_authentication_providers",
|
||||
sa.Column(
|
||||
"id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"name",
|
||||
sa.String(length=256),
|
||||
server_default="API KEY 1",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("end_user_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("provider", sa.Text(), nullable=False),
|
||||
sa.Column("encrypted_credentials", sa.Text(), default="", nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.current_timestamp(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.current_timestamp(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"credential_type",
|
||||
sa.String(length=32),
|
||||
server_default="api-key",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("expires_at", sa.BigInteger(), server_default=sa.text("-1"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"end_user_id",
|
||||
"provider",
|
||||
),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("tool_enduser_authentication_providers")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
@ -80,7 +80,6 @@ from .task import CeleryTask, CeleryTaskSet
|
||||
from .tools import (
|
||||
ApiToolProvider,
|
||||
BuiltinToolProvider,
|
||||
EndUserAuthenticationProvider,
|
||||
ToolConversationVariables,
|
||||
ToolFile,
|
||||
ToolLabelBinding,
|
||||
@ -150,7 +149,6 @@ __all__ = [
|
||||
"DocumentSegment",
|
||||
"Embedding",
|
||||
"EndUser",
|
||||
"EndUserAuthenticationProvider",
|
||||
"ExternalKnowledgeApis",
|
||||
"ExternalKnowledgeBindings",
|
||||
"IconType",
|
||||
|
||||
@ -9,11 +9,9 @@ from deprecated import deprecated
|
||||
from sqlalchemy import ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_bundle import ApiToolBundle
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType, WorkflowToolParameterConfiguration
|
||||
from libs.uuid_utils import uuidv7
|
||||
|
||||
from .base import TypeBase
|
||||
from .engine import db
|
||||
@ -117,59 +115,6 @@ class BuiltinToolProvider(TypeBase):
|
||||
return cast(dict[str, Any], json.loads(self.encrypted_credentials))
|
||||
|
||||
|
||||
class EndUserAuthenticationProvider(TypeBase):
|
||||
"""
|
||||
This table stores the authentication credentials for end users in tools.
|
||||
Mimics the BuiltinToolProvider structure but for end users instead of tenants.
|
||||
"""
|
||||
|
||||
__tablename__ = "tool_enduser_authentication_providers"
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint("end_user_id", "provider"),
|
||||
)
|
||||
|
||||
# id of the authentication provider
|
||||
id: Mapped[str] = mapped_column(StringUUID, primary_key=True, default=lambda: str(uuidv7()), init=False)
|
||||
# id of the tenant
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
# id of the end user
|
||||
end_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
# name of the tool provider
|
||||
provider: Mapped[str] = mapped_column(LongText, nullable=False)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(256),
|
||||
nullable=False,
|
||||
default="API KEY 1",
|
||||
)
|
||||
# encrypted credentials for the end user
|
||||
encrypted_credentials: Mapped[str] = mapped_column(LongText, nullable=False, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime,
|
||||
nullable=False,
|
||||
server_default=func.current_timestamp(),
|
||||
onupdate=func.current_timestamp(),
|
||||
init=False,
|
||||
)
|
||||
# credential type, e.g., "api-key", "oauth2"
|
||||
credential_type: Mapped[CredentialType] = mapped_column(
|
||||
String(32), nullable=False, default=CredentialType.API_KEY
|
||||
)
|
||||
# Unix timestamp in seconds since epoch (1970-01-01 UTC); -1 indicates no expiration
|
||||
expires_at: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, default=-1)
|
||||
|
||||
@property
|
||||
def credentials(self) -> dict[str, Any]:
|
||||
if not self.encrypted_credentials:
|
||||
return {}
|
||||
try:
|
||||
return cast(dict[str, Any], json.loads(self.encrypted_credentials))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
class ApiToolProvider(TypeBase):
|
||||
"""
|
||||
The table stores the api providers.
|
||||
|
||||
@ -19,7 +19,7 @@ class StringUUID(TypeDecorator[uuid.UUID | str | None]):
|
||||
def process_bind_param(self, value: uuid.UUID | str | None, dialect: Dialect) -> str | None:
|
||||
if value is None:
|
||||
return value
|
||||
elif dialect.name in ["postgresql", "mysql"]:
|
||||
elif dialect.name == "postgresql":
|
||||
return str(value)
|
||||
else:
|
||||
if isinstance(value, uuid.UUID):
|
||||
|
||||
@ -10,7 +10,6 @@ from collections.abc import Sequence
|
||||
from typing import Any, Literal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from redis.exceptions import LockNotOwnedError
|
||||
from sqlalchemy import exists, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
@ -1594,176 +1593,173 @@ class DocumentService:
|
||||
db.session.add(dataset_process_rule)
|
||||
db.session.flush()
|
||||
lock_name = f"add_document_lock_dataset_id_{dataset.id}"
|
||||
try:
|
||||
with redis_client.lock(lock_name, timeout=600):
|
||||
assert dataset_process_rule
|
||||
position = DocumentService.get_documents_position(dataset.id)
|
||||
document_ids = []
|
||||
duplicate_document_ids = []
|
||||
if knowledge_config.data_source.info_list.data_source_type == "upload_file":
|
||||
if not knowledge_config.data_source.info_list.file_info_list:
|
||||
raise ValueError("File source info is required")
|
||||
upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids
|
||||
for file_id in upload_file_list:
|
||||
file = (
|
||||
db.session.query(UploadFile)
|
||||
.where(UploadFile.tenant_id == dataset.tenant_id, UploadFile.id == file_id)
|
||||
with redis_client.lock(lock_name, timeout=600):
|
||||
assert dataset_process_rule
|
||||
position = DocumentService.get_documents_position(dataset.id)
|
||||
document_ids = []
|
||||
duplicate_document_ids = []
|
||||
if knowledge_config.data_source.info_list.data_source_type == "upload_file":
|
||||
if not knowledge_config.data_source.info_list.file_info_list:
|
||||
raise ValueError("File source info is required")
|
||||
upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids
|
||||
for file_id in upload_file_list:
|
||||
file = (
|
||||
db.session.query(UploadFile)
|
||||
.where(UploadFile.tenant_id == dataset.tenant_id, UploadFile.id == file_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
# raise error if file not found
|
||||
if not file:
|
||||
raise FileNotExistsError()
|
||||
|
||||
file_name = file.name
|
||||
data_source_info: dict[str, str | bool] = {
|
||||
"upload_file_id": file_id,
|
||||
}
|
||||
# check duplicate
|
||||
if knowledge_config.duplicate:
|
||||
document = (
|
||||
db.session.query(Document)
|
||||
.filter_by(
|
||||
dataset_id=dataset.id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
data_source_type="upload_file",
|
||||
enabled=True,
|
||||
name=file_name,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
# raise error if file not found
|
||||
if not file:
|
||||
raise FileNotExistsError()
|
||||
|
||||
file_name = file.name
|
||||
data_source_info: dict[str, str | bool] = {
|
||||
"upload_file_id": file_id,
|
||||
}
|
||||
# check duplicate
|
||||
if knowledge_config.duplicate:
|
||||
document = (
|
||||
db.session.query(Document)
|
||||
.filter_by(
|
||||
dataset_id=dataset.id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
data_source_type="upload_file",
|
||||
enabled=True,
|
||||
name=file_name,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if document:
|
||||
document.dataset_process_rule_id = dataset_process_rule.id
|
||||
document.updated_at = naive_utc_now()
|
||||
document.created_from = created_from
|
||||
document.doc_form = knowledge_config.doc_form
|
||||
document.doc_language = knowledge_config.doc_language
|
||||
document.data_source_info = json.dumps(data_source_info)
|
||||
document.batch = batch
|
||||
document.indexing_status = "waiting"
|
||||
db.session.add(document)
|
||||
documents.append(document)
|
||||
duplicate_document_ids.append(document.id)
|
||||
continue
|
||||
document = DocumentService.build_document(
|
||||
dataset,
|
||||
dataset_process_rule.id,
|
||||
knowledge_config.data_source.info_list.data_source_type,
|
||||
knowledge_config.doc_form,
|
||||
knowledge_config.doc_language,
|
||||
data_source_info,
|
||||
created_from,
|
||||
position,
|
||||
account,
|
||||
file_name,
|
||||
batch,
|
||||
)
|
||||
db.session.add(document)
|
||||
db.session.flush()
|
||||
document_ids.append(document.id)
|
||||
documents.append(document)
|
||||
position += 1
|
||||
elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
|
||||
notion_info_list = knowledge_config.data_source.info_list.notion_info_list # type: ignore
|
||||
if not notion_info_list:
|
||||
raise ValueError("No notion info list found.")
|
||||
exist_page_ids = []
|
||||
exist_document = {}
|
||||
documents = (
|
||||
db.session.query(Document)
|
||||
.filter_by(
|
||||
dataset_id=dataset.id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
data_source_type="notion_import",
|
||||
enabled=True,
|
||||
)
|
||||
.all()
|
||||
if document:
|
||||
document.dataset_process_rule_id = dataset_process_rule.id
|
||||
document.updated_at = naive_utc_now()
|
||||
document.created_from = created_from
|
||||
document.doc_form = knowledge_config.doc_form
|
||||
document.doc_language = knowledge_config.doc_language
|
||||
document.data_source_info = json.dumps(data_source_info)
|
||||
document.batch = batch
|
||||
document.indexing_status = "waiting"
|
||||
db.session.add(document)
|
||||
documents.append(document)
|
||||
duplicate_document_ids.append(document.id)
|
||||
continue
|
||||
document = DocumentService.build_document(
|
||||
dataset,
|
||||
dataset_process_rule.id,
|
||||
knowledge_config.data_source.info_list.data_source_type,
|
||||
knowledge_config.doc_form,
|
||||
knowledge_config.doc_language,
|
||||
data_source_info,
|
||||
created_from,
|
||||
position,
|
||||
account,
|
||||
file_name,
|
||||
batch,
|
||||
)
|
||||
if documents:
|
||||
for document in documents:
|
||||
data_source_info = json.loads(document.data_source_info)
|
||||
exist_page_ids.append(data_source_info["notion_page_id"])
|
||||
exist_document[data_source_info["notion_page_id"]] = document.id
|
||||
for notion_info in notion_info_list:
|
||||
workspace_id = notion_info.workspace_id
|
||||
for page in notion_info.pages:
|
||||
if page.page_id not in exist_page_ids:
|
||||
data_source_info = {
|
||||
"credential_id": notion_info.credential_id,
|
||||
"notion_workspace_id": workspace_id,
|
||||
"notion_page_id": page.page_id,
|
||||
"notion_page_icon": page.page_icon.model_dump() if page.page_icon else None, # type: ignore
|
||||
"type": page.type,
|
||||
}
|
||||
# Truncate page name to 255 characters to prevent DB field length errors
|
||||
truncated_page_name = page.page_name[:255] if page.page_name else "nopagename"
|
||||
document = DocumentService.build_document(
|
||||
dataset,
|
||||
dataset_process_rule.id,
|
||||
knowledge_config.data_source.info_list.data_source_type,
|
||||
knowledge_config.doc_form,
|
||||
knowledge_config.doc_language,
|
||||
data_source_info,
|
||||
created_from,
|
||||
position,
|
||||
account,
|
||||
truncated_page_name,
|
||||
batch,
|
||||
)
|
||||
db.session.add(document)
|
||||
db.session.flush()
|
||||
document_ids.append(document.id)
|
||||
documents.append(document)
|
||||
position += 1
|
||||
else:
|
||||
exist_document.pop(page.page_id)
|
||||
# delete not selected documents
|
||||
if len(exist_document) > 0:
|
||||
clean_notion_document_task.delay(list(exist_document.values()), dataset.id)
|
||||
elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
|
||||
website_info = knowledge_config.data_source.info_list.website_info_list
|
||||
if not website_info:
|
||||
raise ValueError("No website info list found.")
|
||||
urls = website_info.urls
|
||||
for url in urls:
|
||||
data_source_info = {
|
||||
"url": url,
|
||||
"provider": website_info.provider,
|
||||
"job_id": website_info.job_id,
|
||||
"only_main_content": website_info.only_main_content,
|
||||
"mode": "crawl",
|
||||
}
|
||||
if len(url) > 255:
|
||||
document_name = url[:200] + "..."
|
||||
db.session.add(document)
|
||||
db.session.flush()
|
||||
document_ids.append(document.id)
|
||||
documents.append(document)
|
||||
position += 1
|
||||
elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
|
||||
notion_info_list = knowledge_config.data_source.info_list.notion_info_list # type: ignore
|
||||
if not notion_info_list:
|
||||
raise ValueError("No notion info list found.")
|
||||
exist_page_ids = []
|
||||
exist_document = {}
|
||||
documents = (
|
||||
db.session.query(Document)
|
||||
.filter_by(
|
||||
dataset_id=dataset.id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
data_source_type="notion_import",
|
||||
enabled=True,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if documents:
|
||||
for document in documents:
|
||||
data_source_info = json.loads(document.data_source_info)
|
||||
exist_page_ids.append(data_source_info["notion_page_id"])
|
||||
exist_document[data_source_info["notion_page_id"]] = document.id
|
||||
for notion_info in notion_info_list:
|
||||
workspace_id = notion_info.workspace_id
|
||||
for page in notion_info.pages:
|
||||
if page.page_id not in exist_page_ids:
|
||||
data_source_info = {
|
||||
"credential_id": notion_info.credential_id,
|
||||
"notion_workspace_id": workspace_id,
|
||||
"notion_page_id": page.page_id,
|
||||
"notion_page_icon": page.page_icon.model_dump() if page.page_icon else None, # type: ignore
|
||||
"type": page.type,
|
||||
}
|
||||
# Truncate page name to 255 characters to prevent DB field length errors
|
||||
truncated_page_name = page.page_name[:255] if page.page_name else "nopagename"
|
||||
document = DocumentService.build_document(
|
||||
dataset,
|
||||
dataset_process_rule.id,
|
||||
knowledge_config.data_source.info_list.data_source_type,
|
||||
knowledge_config.doc_form,
|
||||
knowledge_config.doc_language,
|
||||
data_source_info,
|
||||
created_from,
|
||||
position,
|
||||
account,
|
||||
truncated_page_name,
|
||||
batch,
|
||||
)
|
||||
db.session.add(document)
|
||||
db.session.flush()
|
||||
document_ids.append(document.id)
|
||||
documents.append(document)
|
||||
position += 1
|
||||
else:
|
||||
document_name = url
|
||||
document = DocumentService.build_document(
|
||||
dataset,
|
||||
dataset_process_rule.id,
|
||||
knowledge_config.data_source.info_list.data_source_type,
|
||||
knowledge_config.doc_form,
|
||||
knowledge_config.doc_language,
|
||||
data_source_info,
|
||||
created_from,
|
||||
position,
|
||||
account,
|
||||
document_name,
|
||||
batch,
|
||||
)
|
||||
db.session.add(document)
|
||||
db.session.flush()
|
||||
document_ids.append(document.id)
|
||||
documents.append(document)
|
||||
position += 1
|
||||
db.session.commit()
|
||||
exist_document.pop(page.page_id)
|
||||
# delete not selected documents
|
||||
if len(exist_document) > 0:
|
||||
clean_notion_document_task.delay(list(exist_document.values()), dataset.id)
|
||||
elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
|
||||
website_info = knowledge_config.data_source.info_list.website_info_list
|
||||
if not website_info:
|
||||
raise ValueError("No website info list found.")
|
||||
urls = website_info.urls
|
||||
for url in urls:
|
||||
data_source_info = {
|
||||
"url": url,
|
||||
"provider": website_info.provider,
|
||||
"job_id": website_info.job_id,
|
||||
"only_main_content": website_info.only_main_content,
|
||||
"mode": "crawl",
|
||||
}
|
||||
if len(url) > 255:
|
||||
document_name = url[:200] + "..."
|
||||
else:
|
||||
document_name = url
|
||||
document = DocumentService.build_document(
|
||||
dataset,
|
||||
dataset_process_rule.id,
|
||||
knowledge_config.data_source.info_list.data_source_type,
|
||||
knowledge_config.doc_form,
|
||||
knowledge_config.doc_language,
|
||||
data_source_info,
|
||||
created_from,
|
||||
position,
|
||||
account,
|
||||
document_name,
|
||||
batch,
|
||||
)
|
||||
db.session.add(document)
|
||||
db.session.flush()
|
||||
document_ids.append(document.id)
|
||||
documents.append(document)
|
||||
position += 1
|
||||
db.session.commit()
|
||||
|
||||
# trigger async task
|
||||
if document_ids:
|
||||
DocumentIndexingTaskProxy(dataset.tenant_id, dataset.id, document_ids).delay()
|
||||
if duplicate_document_ids:
|
||||
duplicate_document_indexing_task.delay(dataset.id, duplicate_document_ids)
|
||||
except LockNotOwnedError:
|
||||
pass
|
||||
# trigger async task
|
||||
if document_ids:
|
||||
DocumentIndexingTaskProxy(dataset.tenant_id, dataset.id, document_ids).delay()
|
||||
if duplicate_document_ids:
|
||||
duplicate_document_indexing_task.delay(dataset.id, duplicate_document_ids)
|
||||
|
||||
return documents, batch
|
||||
|
||||
@ -2703,55 +2699,50 @@ class SegmentService:
|
||||
# calc embedding use tokens
|
||||
tokens = embedding_model.get_text_embedding_num_tokens(texts=[content])[0]
|
||||
lock_name = f"add_segment_lock_document_id_{document.id}"
|
||||
try:
|
||||
with redis_client.lock(lock_name, timeout=600):
|
||||
max_position = (
|
||||
db.session.query(func.max(DocumentSegment.position))
|
||||
.where(DocumentSegment.document_id == document.id)
|
||||
.scalar()
|
||||
)
|
||||
segment_document = DocumentSegment(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
dataset_id=document.dataset_id,
|
||||
document_id=document.id,
|
||||
index_node_id=doc_id,
|
||||
index_node_hash=segment_hash,
|
||||
position=max_position + 1 if max_position else 1,
|
||||
content=content,
|
||||
word_count=len(content),
|
||||
tokens=tokens,
|
||||
status="completed",
|
||||
indexing_at=naive_utc_now(),
|
||||
completed_at=naive_utc_now(),
|
||||
created_by=current_user.id,
|
||||
)
|
||||
if document.doc_form == "qa_model":
|
||||
segment_document.word_count += len(args["answer"])
|
||||
segment_document.answer = args["answer"]
|
||||
with redis_client.lock(lock_name, timeout=600):
|
||||
max_position = (
|
||||
db.session.query(func.max(DocumentSegment.position))
|
||||
.where(DocumentSegment.document_id == document.id)
|
||||
.scalar()
|
||||
)
|
||||
segment_document = DocumentSegment(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
dataset_id=document.dataset_id,
|
||||
document_id=document.id,
|
||||
index_node_id=doc_id,
|
||||
index_node_hash=segment_hash,
|
||||
position=max_position + 1 if max_position else 1,
|
||||
content=content,
|
||||
word_count=len(content),
|
||||
tokens=tokens,
|
||||
status="completed",
|
||||
indexing_at=naive_utc_now(),
|
||||
completed_at=naive_utc_now(),
|
||||
created_by=current_user.id,
|
||||
)
|
||||
if document.doc_form == "qa_model":
|
||||
segment_document.word_count += len(args["answer"])
|
||||
segment_document.answer = args["answer"]
|
||||
|
||||
db.session.add(segment_document)
|
||||
# update document word count
|
||||
assert document.word_count is not None
|
||||
document.word_count += segment_document.word_count
|
||||
db.session.add(document)
|
||||
db.session.add(segment_document)
|
||||
# update document word count
|
||||
assert document.word_count is not None
|
||||
document.word_count += segment_document.word_count
|
||||
db.session.add(document)
|
||||
db.session.commit()
|
||||
|
||||
# save vector index
|
||||
try:
|
||||
VectorService.create_segments_vector([args["keywords"]], [segment_document], dataset, document.doc_form)
|
||||
except Exception as e:
|
||||
logger.exception("create segment index failed")
|
||||
segment_document.enabled = False
|
||||
segment_document.disabled_at = naive_utc_now()
|
||||
segment_document.status = "error"
|
||||
segment_document.error = str(e)
|
||||
db.session.commit()
|
||||
|
||||
# save vector index
|
||||
try:
|
||||
VectorService.create_segments_vector(
|
||||
[args["keywords"]], [segment_document], dataset, document.doc_form
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("create segment index failed")
|
||||
segment_document.enabled = False
|
||||
segment_document.disabled_at = naive_utc_now()
|
||||
segment_document.status = "error"
|
||||
segment_document.error = str(e)
|
||||
db.session.commit()
|
||||
segment = db.session.query(DocumentSegment).where(DocumentSegment.id == segment_document.id).first()
|
||||
return segment
|
||||
except LockNotOwnedError:
|
||||
pass
|
||||
segment = db.session.query(DocumentSegment).where(DocumentSegment.id == segment_document.id).first()
|
||||
return segment
|
||||
|
||||
@classmethod
|
||||
def multi_create_segment(cls, segments: list, document: Document, dataset: Dataset):
|
||||
@ -2760,89 +2751,84 @@ class SegmentService:
|
||||
|
||||
lock_name = f"multi_add_segment_lock_document_id_{document.id}"
|
||||
increment_word_count = 0
|
||||
try:
|
||||
with redis_client.lock(lock_name, timeout=600):
|
||||
embedding_model = None
|
||||
if dataset.indexing_technique == "high_quality":
|
||||
model_manager = ModelManager()
|
||||
embedding_model = model_manager.get_model_instance(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
provider=dataset.embedding_model_provider,
|
||||
model_type=ModelType.TEXT_EMBEDDING,
|
||||
model=dataset.embedding_model,
|
||||
)
|
||||
max_position = (
|
||||
db.session.query(func.max(DocumentSegment.position))
|
||||
.where(DocumentSegment.document_id == document.id)
|
||||
.scalar()
|
||||
with redis_client.lock(lock_name, timeout=600):
|
||||
embedding_model = None
|
||||
if dataset.indexing_technique == "high_quality":
|
||||
model_manager = ModelManager()
|
||||
embedding_model = model_manager.get_model_instance(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
provider=dataset.embedding_model_provider,
|
||||
model_type=ModelType.TEXT_EMBEDDING,
|
||||
model=dataset.embedding_model,
|
||||
)
|
||||
pre_segment_data_list = []
|
||||
segment_data_list = []
|
||||
keywords_list = []
|
||||
position = max_position + 1 if max_position else 1
|
||||
for segment_item in segments:
|
||||
content = segment_item["content"]
|
||||
doc_id = str(uuid.uuid4())
|
||||
segment_hash = helper.generate_text_hash(content)
|
||||
tokens = 0
|
||||
if dataset.indexing_technique == "high_quality" and embedding_model:
|
||||
# calc embedding use tokens
|
||||
if document.doc_form == "qa_model":
|
||||
tokens = embedding_model.get_text_embedding_num_tokens(
|
||||
texts=[content + segment_item["answer"]]
|
||||
)[0]
|
||||
else:
|
||||
tokens = embedding_model.get_text_embedding_num_tokens(texts=[content])[0]
|
||||
|
||||
segment_document = DocumentSegment(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
dataset_id=document.dataset_id,
|
||||
document_id=document.id,
|
||||
index_node_id=doc_id,
|
||||
index_node_hash=segment_hash,
|
||||
position=position,
|
||||
content=content,
|
||||
word_count=len(content),
|
||||
tokens=tokens,
|
||||
keywords=segment_item.get("keywords", []),
|
||||
status="completed",
|
||||
indexing_at=naive_utc_now(),
|
||||
completed_at=naive_utc_now(),
|
||||
created_by=current_user.id,
|
||||
)
|
||||
max_position = (
|
||||
db.session.query(func.max(DocumentSegment.position))
|
||||
.where(DocumentSegment.document_id == document.id)
|
||||
.scalar()
|
||||
)
|
||||
pre_segment_data_list = []
|
||||
segment_data_list = []
|
||||
keywords_list = []
|
||||
position = max_position + 1 if max_position else 1
|
||||
for segment_item in segments:
|
||||
content = segment_item["content"]
|
||||
doc_id = str(uuid.uuid4())
|
||||
segment_hash = helper.generate_text_hash(content)
|
||||
tokens = 0
|
||||
if dataset.indexing_technique == "high_quality" and embedding_model:
|
||||
# calc embedding use tokens
|
||||
if document.doc_form == "qa_model":
|
||||
segment_document.answer = segment_item["answer"]
|
||||
segment_document.word_count += len(segment_item["answer"])
|
||||
increment_word_count += segment_document.word_count
|
||||
db.session.add(segment_document)
|
||||
segment_data_list.append(segment_document)
|
||||
position += 1
|
||||
|
||||
pre_segment_data_list.append(segment_document)
|
||||
if "keywords" in segment_item:
|
||||
keywords_list.append(segment_item["keywords"])
|
||||
tokens = embedding_model.get_text_embedding_num_tokens(
|
||||
texts=[content + segment_item["answer"]]
|
||||
)[0]
|
||||
else:
|
||||
keywords_list.append(None)
|
||||
# update document word count
|
||||
assert document.word_count is not None
|
||||
document.word_count += increment_word_count
|
||||
db.session.add(document)
|
||||
try:
|
||||
# save vector index
|
||||
VectorService.create_segments_vector(
|
||||
keywords_list, pre_segment_data_list, dataset, document.doc_form
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("create segment index failed")
|
||||
for segment_document in segment_data_list:
|
||||
segment_document.enabled = False
|
||||
segment_document.disabled_at = naive_utc_now()
|
||||
segment_document.status = "error"
|
||||
segment_document.error = str(e)
|
||||
db.session.commit()
|
||||
return segment_data_list
|
||||
except LockNotOwnedError:
|
||||
pass
|
||||
tokens = embedding_model.get_text_embedding_num_tokens(texts=[content])[0]
|
||||
|
||||
segment_document = DocumentSegment(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
dataset_id=document.dataset_id,
|
||||
document_id=document.id,
|
||||
index_node_id=doc_id,
|
||||
index_node_hash=segment_hash,
|
||||
position=position,
|
||||
content=content,
|
||||
word_count=len(content),
|
||||
tokens=tokens,
|
||||
keywords=segment_item.get("keywords", []),
|
||||
status="completed",
|
||||
indexing_at=naive_utc_now(),
|
||||
completed_at=naive_utc_now(),
|
||||
created_by=current_user.id,
|
||||
)
|
||||
if document.doc_form == "qa_model":
|
||||
segment_document.answer = segment_item["answer"]
|
||||
segment_document.word_count += len(segment_item["answer"])
|
||||
increment_word_count += segment_document.word_count
|
||||
db.session.add(segment_document)
|
||||
segment_data_list.append(segment_document)
|
||||
position += 1
|
||||
|
||||
pre_segment_data_list.append(segment_document)
|
||||
if "keywords" in segment_item:
|
||||
keywords_list.append(segment_item["keywords"])
|
||||
else:
|
||||
keywords_list.append(None)
|
||||
# update document word count
|
||||
assert document.word_count is not None
|
||||
document.word_count += increment_word_count
|
||||
db.session.add(document)
|
||||
try:
|
||||
# save vector index
|
||||
VectorService.create_segments_vector(keywords_list, pre_segment_data_list, dataset, document.doc_form)
|
||||
except Exception as e:
|
||||
logger.exception("create segment index failed")
|
||||
for segment_document in segment_data_list:
|
||||
segment_document.enabled = False
|
||||
segment_document.disabled_at = naive_utc_now()
|
||||
segment_document.status = "error"
|
||||
segment_document.error = str(e)
|
||||
db.session.commit()
|
||||
return segment_data_list
|
||||
|
||||
@classmethod
|
||||
def update_segment(cls, args: SegmentUpdateArgs, segment: DocumentSegment, document: Document, dataset: Dataset):
|
||||
|
||||
@ -69,7 +69,6 @@ class ProviderResponse(BaseModel):
|
||||
label: I18nObject
|
||||
description: I18nObject | None = None
|
||||
icon_small: I18nObject | None = None
|
||||
icon_small_dark: I18nObject | None = None
|
||||
icon_large: I18nObject | None = None
|
||||
background: str | None = None
|
||||
help: ProviderHelpEntity | None = None
|
||||
@ -93,11 +92,6 @@ class ProviderResponse(BaseModel):
|
||||
self.icon_small = I18nObject(
|
||||
en_US=f"{url_prefix}/icon_small/en_US", zh_Hans=f"{url_prefix}/icon_small/zh_Hans"
|
||||
)
|
||||
if self.icon_small_dark is not None:
|
||||
self.icon_small_dark = I18nObject(
|
||||
en_US=f"{url_prefix}/icon_small_dark/en_US",
|
||||
zh_Hans=f"{url_prefix}/icon_small_dark/zh_Hans",
|
||||
)
|
||||
|
||||
if self.icon_large is not None:
|
||||
self.icon_large = I18nObject(
|
||||
@ -115,7 +109,6 @@ class ProviderWithModelsResponse(BaseModel):
|
||||
provider: str
|
||||
label: I18nObject
|
||||
icon_small: I18nObject | None = None
|
||||
icon_small_dark: I18nObject | None = None
|
||||
icon_large: I18nObject | None = None
|
||||
status: CustomConfigurationStatus
|
||||
models: list[ProviderModelWithStatusEntity]
|
||||
@ -130,11 +123,6 @@ class ProviderWithModelsResponse(BaseModel):
|
||||
en_US=f"{url_prefix}/icon_small/en_US", zh_Hans=f"{url_prefix}/icon_small/zh_Hans"
|
||||
)
|
||||
|
||||
if self.icon_small_dark is not None:
|
||||
self.icon_small_dark = I18nObject(
|
||||
en_US=f"{url_prefix}/icon_small_dark/en_US", zh_Hans=f"{url_prefix}/icon_small_dark/zh_Hans"
|
||||
)
|
||||
|
||||
if self.icon_large is not None:
|
||||
self.icon_large = I18nObject(
|
||||
en_US=f"{url_prefix}/icon_large/en_US", zh_Hans=f"{url_prefix}/icon_large/zh_Hans"
|
||||
@ -159,11 +147,6 @@ class SimpleProviderEntityResponse(SimpleProviderEntity):
|
||||
en_US=f"{url_prefix}/icon_small/en_US", zh_Hans=f"{url_prefix}/icon_small/zh_Hans"
|
||||
)
|
||||
|
||||
if self.icon_small_dark is not None:
|
||||
self.icon_small_dark = I18nObject(
|
||||
en_US=f"{url_prefix}/icon_small_dark/en_US", zh_Hans=f"{url_prefix}/icon_small_dark/zh_Hans"
|
||||
)
|
||||
|
||||
if self.icon_large is not None:
|
||||
self.icon_large = I18nObject(
|
||||
en_US=f"{url_prefix}/icon_large/en_US", zh_Hans=f"{url_prefix}/icon_large/zh_Hans"
|
||||
|
||||
@ -79,7 +79,6 @@ class ModelProviderService:
|
||||
label=provider_configuration.provider.label,
|
||||
description=provider_configuration.provider.description,
|
||||
icon_small=provider_configuration.provider.icon_small,
|
||||
icon_small_dark=provider_configuration.provider.icon_small_dark,
|
||||
icon_large=provider_configuration.provider.icon_large,
|
||||
background=provider_configuration.provider.background,
|
||||
help=provider_configuration.provider.help,
|
||||
@ -403,7 +402,6 @@ class ModelProviderService:
|
||||
provider=provider,
|
||||
label=first_model.provider.label,
|
||||
icon_small=first_model.provider.icon_small,
|
||||
icon_small_dark=first_model.provider.icon_small_dark,
|
||||
icon_large=first_model.provider.icon_large,
|
||||
status=CustomConfigurationStatus.ACTIVE,
|
||||
models=[
|
||||
|
||||
@ -233,7 +233,7 @@ workflow:
|
||||
- value_selector:
|
||||
- iteration_node
|
||||
- output
|
||||
value_type: array[number]
|
||||
value_type: array[array[number]]
|
||||
variable: output
|
||||
selected: false
|
||||
title: End
|
||||
|
||||
@ -227,7 +227,6 @@ class TestModelProviderService:
|
||||
mock_provider_entity.label = {"en_US": "OpenAI", "zh_Hans": "OpenAI"}
|
||||
mock_provider_entity.description = {"en_US": "OpenAI provider", "zh_Hans": "OpenAI 提供商"}
|
||||
mock_provider_entity.icon_small = {"en_US": "icon_small.png", "zh_Hans": "icon_small.png"}
|
||||
mock_provider_entity.icon_small_dark = None
|
||||
mock_provider_entity.icon_large = {"en_US": "icon_large.png", "zh_Hans": "icon_large.png"}
|
||||
mock_provider_entity.background = "#FF6B6B"
|
||||
mock_provider_entity.help = None
|
||||
@ -301,7 +300,6 @@ class TestModelProviderService:
|
||||
mock_provider_entity_llm.label = {"en_US": "OpenAI", "zh_Hans": "OpenAI"}
|
||||
mock_provider_entity_llm.description = {"en_US": "OpenAI provider", "zh_Hans": "OpenAI 提供商"}
|
||||
mock_provider_entity_llm.icon_small = {"en_US": "icon_small.png", "zh_Hans": "icon_small.png"}
|
||||
mock_provider_entity_llm.icon_small_dark = None
|
||||
mock_provider_entity_llm.icon_large = {"en_US": "icon_large.png", "zh_Hans": "icon_large.png"}
|
||||
mock_provider_entity_llm.background = "#FF6B6B"
|
||||
mock_provider_entity_llm.help = None
|
||||
@ -315,7 +313,6 @@ class TestModelProviderService:
|
||||
mock_provider_entity_embedding.label = {"en_US": "Cohere", "zh_Hans": "Cohere"}
|
||||
mock_provider_entity_embedding.description = {"en_US": "Cohere provider", "zh_Hans": "Cohere 提供商"}
|
||||
mock_provider_entity_embedding.icon_small = {"en_US": "icon_small.png", "zh_Hans": "icon_small.png"}
|
||||
mock_provider_entity_embedding.icon_small_dark = None
|
||||
mock_provider_entity_embedding.icon_large = {"en_US": "icon_large.png", "zh_Hans": "icon_large.png"}
|
||||
mock_provider_entity_embedding.background = "#4ECDC4"
|
||||
mock_provider_entity_embedding.help = None
|
||||
@ -1026,7 +1023,6 @@ class TestModelProviderService:
|
||||
provider="openai",
|
||||
label={"en_US": "OpenAI", "zh_Hans": "OpenAI"},
|
||||
icon_small={"en_US": "icon_small.png", "zh_Hans": "icon_small.png"},
|
||||
icon_small_dark=None,
|
||||
icon_large={"en_US": "icon_large.png", "zh_Hans": "icon_large.png"},
|
||||
),
|
||||
model="gpt-3.5-turbo",
|
||||
@ -1044,7 +1040,6 @@ class TestModelProviderService:
|
||||
provider="openai",
|
||||
label={"en_US": "OpenAI", "zh_Hans": "OpenAI"},
|
||||
icon_small={"en_US": "icon_small.png", "zh_Hans": "icon_small.png"},
|
||||
icon_small_dark=None,
|
||||
icon_large={"en_US": "icon_large.png", "zh_Hans": "icon_large.png"},
|
||||
),
|
||||
model="gpt-4",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,3 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
@ -216,76 +214,3 @@ def test_create_variable_message():
|
||||
assert message.message.variable_name == var_name
|
||||
assert message.message.variable_value == var_value
|
||||
assert message.message.stream is False
|
||||
|
||||
|
||||
def test_resolve_user_from_database_falls_back_to_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Ensure worker context can resolve EndUser when Account is missing."""
|
||||
|
||||
class StubSession:
|
||||
def __init__(self, results: list):
|
||||
self.results = results
|
||||
|
||||
def scalar(self, _stmt):
|
||||
return self.results.pop(0)
|
||||
|
||||
tenant = SimpleNamespace(id="tenant_id")
|
||||
end_user = SimpleNamespace(id="end_user_id", tenant_id="tenant_id")
|
||||
db_stub = SimpleNamespace(session=StubSession([tenant, None, end_user]))
|
||||
|
||||
monkeypatch.setattr("core.tools.workflow_as_tool.tool.db", db_stub)
|
||||
|
||||
entity = ToolEntity(
|
||||
identity=ToolIdentity(author="test", name="test tool", label=I18nObject(en_US="test tool"), provider="test"),
|
||||
parameters=[],
|
||||
description=None,
|
||||
has_runtime_parameters=False,
|
||||
)
|
||||
runtime = ToolRuntime(tenant_id="tenant_id", invoke_from=InvokeFrom.SERVICE_API)
|
||||
tool = WorkflowTool(
|
||||
workflow_app_id="",
|
||||
workflow_as_tool_id="",
|
||||
version="1",
|
||||
workflow_entities={},
|
||||
workflow_call_depth=1,
|
||||
entity=entity,
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
resolved_user = tool._resolve_user_from_database(user_id=end_user.id)
|
||||
|
||||
assert resolved_user is end_user
|
||||
|
||||
|
||||
def test_resolve_user_from_database_returns_none_when_no_tenant(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Return None if tenant cannot be found in worker context."""
|
||||
|
||||
class StubSession:
|
||||
def __init__(self, results: list):
|
||||
self.results = results
|
||||
|
||||
def scalar(self, _stmt):
|
||||
return self.results.pop(0)
|
||||
|
||||
db_stub = SimpleNamespace(session=StubSession([None]))
|
||||
monkeypatch.setattr("core.tools.workflow_as_tool.tool.db", db_stub)
|
||||
|
||||
entity = ToolEntity(
|
||||
identity=ToolIdentity(author="test", name="test tool", label=I18nObject(en_US="test tool"), provider="test"),
|
||||
parameters=[],
|
||||
description=None,
|
||||
has_runtime_parameters=False,
|
||||
)
|
||||
runtime = ToolRuntime(tenant_id="missing_tenant", invoke_from=InvokeFrom.SERVICE_API)
|
||||
tool = WorkflowTool(
|
||||
workflow_app_id="",
|
||||
workflow_as_tool_id="",
|
||||
version="1",
|
||||
workflow_entities={},
|
||||
workflow_call_depth=1,
|
||||
entity=entity,
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
resolved_user = tool._resolve_user_from_database(user_id="any")
|
||||
|
||||
assert resolved_user is None
|
||||
|
||||
@ -7,31 +7,9 @@ This module tests the iteration node's ability to:
|
||||
"""
|
||||
|
||||
from .test_database_utils import skip_if_database_unavailable
|
||||
from .test_mock_config import MockConfigBuilder, NodeMockConfig
|
||||
from .test_table_runner import TableTestRunner, WorkflowTestCase
|
||||
|
||||
|
||||
def _create_iteration_mock_config():
|
||||
"""Helper to create a mock config for iteration tests."""
|
||||
|
||||
def code_inner_handler(node):
|
||||
pool = node.graph_runtime_state.variable_pool
|
||||
item_seg = pool.get(["iteration_node", "item"])
|
||||
if item_seg is not None:
|
||||
item = item_seg.to_object()
|
||||
return {"result": [item, item * 2]}
|
||||
# This fallback is likely unreachable, but if it is,
|
||||
# it doesn't simulate iteration with different values as the comment suggests.
|
||||
return {"result": [1, 2]}
|
||||
|
||||
return (
|
||||
MockConfigBuilder()
|
||||
.with_node_output("code_node", {"result": [1, 2, 3]})
|
||||
.with_node_config(NodeMockConfig(node_id="code_inner_node", custom_handler=code_inner_handler))
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
@skip_if_database_unavailable()
|
||||
def test_iteration_with_flatten_output_enabled():
|
||||
"""
|
||||
@ -49,8 +27,7 @@ def test_iteration_with_flatten_output_enabled():
|
||||
inputs={},
|
||||
expected_outputs={"output": [1, 2, 2, 4, 3, 6]},
|
||||
description="Iteration with flatten_output=True flattens nested arrays",
|
||||
use_auto_mock=True, # Use auto-mock to avoid sandbox service
|
||||
mock_config=_create_iteration_mock_config(),
|
||||
use_auto_mock=False, # Run code nodes directly
|
||||
)
|
||||
|
||||
result = runner.run_test_case(test_case)
|
||||
@ -79,8 +56,7 @@ def test_iteration_with_flatten_output_disabled():
|
||||
inputs={},
|
||||
expected_outputs={"output": [[1, 2], [2, 4], [3, 6]]},
|
||||
description="Iteration with flatten_output=False preserves nested structure",
|
||||
use_auto_mock=True, # Use auto-mock to avoid sandbox service
|
||||
mock_config=_create_iteration_mock_config(),
|
||||
use_auto_mock=False, # Run code nodes directly
|
||||
)
|
||||
|
||||
result = runner.run_test_case(test_case)
|
||||
@ -105,16 +81,14 @@ def test_iteration_flatten_output_comparison():
|
||||
inputs={},
|
||||
expected_outputs={"output": [1, 2, 2, 4, 3, 6]},
|
||||
description="flatten_output=True: Flattened output",
|
||||
use_auto_mock=True, # Use auto-mock to avoid sandbox service
|
||||
mock_config=_create_iteration_mock_config(),
|
||||
use_auto_mock=False, # Run code nodes directly
|
||||
),
|
||||
WorkflowTestCase(
|
||||
fixture_path="iteration_flatten_output_disabled_workflow",
|
||||
inputs={},
|
||||
expected_outputs={"output": [[1, 2], [2, 4], [3, 6]]},
|
||||
description="flatten_output=False: Nested output",
|
||||
use_auto_mock=True, # Use auto-mock to avoid sandbox service
|
||||
mock_config=_create_iteration_mock_config(),
|
||||
use_auto_mock=False, # Run code nodes directly
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@ -263,62 +263,3 @@ class TestResponseUnmodified:
|
||||
)
|
||||
assert response.text == _RESPONSE_NEEDLE
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestRequestFinishedInfoAccessLine:
|
||||
def test_info_access_log_includes_method_path_status_duration_trace_id(self, monkeypatch, caplog):
|
||||
"""Ensure INFO access line contains expected fields with computed duration and trace id."""
|
||||
app = _get_test_app()
|
||||
# Push a real request context so flask.request and g are available
|
||||
with app.test_request_context("/foo", method="GET"):
|
||||
# Seed start timestamp via the extension's own start hook and control perf_counter deterministically
|
||||
seq = iter([100.0, 100.123456])
|
||||
monkeypatch.setattr(ext_request_logging.time, "perf_counter", lambda: next(seq))
|
||||
# Provide a deterministic trace id
|
||||
monkeypatch.setattr(
|
||||
ext_request_logging,
|
||||
"get_trace_id_from_otel_context",
|
||||
lambda: "trace-xyz",
|
||||
)
|
||||
# Simulate request_started to record start timestamp on g
|
||||
ext_request_logging._log_request_started(app)
|
||||
|
||||
# Capture logs from the real logger at INFO level only (skip DEBUG branch)
|
||||
caplog.set_level(logging.INFO, logger=ext_request_logging.__name__)
|
||||
response = Response(json.dumps({"ok": True}), mimetype="application/json", status=200)
|
||||
_log_request_finished(app, response)
|
||||
|
||||
# Verify a single INFO record with the five fields in order
|
||||
info_records = [rec for rec in caplog.records if rec.levelno == logging.INFO]
|
||||
assert len(info_records) == 1
|
||||
msg = info_records[0].getMessage()
|
||||
# Expected format: METHOD PATH STATUS DURATION_MS TRACE_ID
|
||||
assert "GET" in msg
|
||||
assert "/foo" in msg
|
||||
assert "200" in msg
|
||||
assert "123.456" in msg # rounded to 3 decimals
|
||||
assert "trace-xyz" in msg
|
||||
|
||||
def test_info_access_log_uses_dash_without_start_timestamp(self, monkeypatch, caplog):
|
||||
app = _get_test_app()
|
||||
with app.test_request_context("/bar", method="POST"):
|
||||
# No g.__request_started_ts set -> duration should be '-'
|
||||
monkeypatch.setattr(
|
||||
ext_request_logging,
|
||||
"get_trace_id_from_otel_context",
|
||||
lambda: "tid-no-start",
|
||||
)
|
||||
caplog.set_level(logging.INFO, logger=ext_request_logging.__name__)
|
||||
response = Response("OK", mimetype="text/plain", status=204)
|
||||
_log_request_finished(app, response)
|
||||
|
||||
info_records = [rec for rec in caplog.records if rec.levelno == logging.INFO]
|
||||
assert len(info_records) == 1
|
||||
msg = info_records[0].getMessage()
|
||||
assert "POST" in msg
|
||||
assert "/bar" in msg
|
||||
assert "204" in msg
|
||||
# Duration placeholder
|
||||
# The fields are space separated; ensure a standalone '-' appears
|
||||
assert " - " in msg or msg.endswith(" -")
|
||||
assert "tid-no-start" in msg
|
||||
|
||||
@ -1,177 +0,0 @@
|
||||
import types
|
||||
from unittest.mock import Mock, create_autospec
|
||||
|
||||
import pytest
|
||||
from redis.exceptions import LockNotOwnedError
|
||||
|
||||
from models.account import Account
|
||||
from models.dataset import Dataset, Document
|
||||
from services.dataset_service import DocumentService, SegmentService
|
||||
|
||||
|
||||
class FakeLock:
|
||||
"""Lock that always fails on enter with LockNotOwnedError."""
|
||||
|
||||
def __enter__(self):
|
||||
raise LockNotOwnedError("simulated")
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
# Normal contextmanager signature; return False so exceptions propagate
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_current_user(monkeypatch):
|
||||
user = create_autospec(Account, instance=True)
|
||||
user.id = "user-1"
|
||||
user.current_tenant_id = "tenant-1"
|
||||
monkeypatch.setattr("services.dataset_service.current_user", user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_features(monkeypatch):
|
||||
"""Features.billing.enabled == False to skip quota logic."""
|
||||
features = types.SimpleNamespace(
|
||||
billing=types.SimpleNamespace(enabled=False, subscription=types.SimpleNamespace(plan="ENTERPRISE")),
|
||||
documents_upload_quota=types.SimpleNamespace(limit=10_000, size=0),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.dataset_service.FeatureService.get_features",
|
||||
lambda tenant_id: features,
|
||||
)
|
||||
return features
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_lock(monkeypatch):
|
||||
"""Patch redis_client.lock to always raise LockNotOwnedError on enter."""
|
||||
|
||||
def _fake_lock(name, timeout=None, *args, **kwargs):
|
||||
return FakeLock()
|
||||
|
||||
# DatasetService imports redis_client directly from extensions.ext_redis
|
||||
monkeypatch.setattr("services.dataset_service.redis_client.lock", _fake_lock)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Knowledge Pipeline document creation (save_document_with_dataset_id)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_document_with_dataset_id_ignores_lock_not_owned(
|
||||
monkeypatch,
|
||||
fake_current_user,
|
||||
fake_features,
|
||||
fake_lock,
|
||||
):
|
||||
# Arrange
|
||||
dataset = create_autospec(Dataset, instance=True)
|
||||
dataset.id = "ds-1"
|
||||
dataset.tenant_id = fake_current_user.current_tenant_id
|
||||
dataset.data_source_type = "upload_file"
|
||||
dataset.indexing_technique = "high_quality" # so we skip re-initialization branch
|
||||
|
||||
# Minimal knowledge_config stub that satisfies pre-lock code
|
||||
info_list = types.SimpleNamespace(data_source_type="upload_file")
|
||||
data_source = types.SimpleNamespace(info_list=info_list)
|
||||
knowledge_config = types.SimpleNamespace(
|
||||
doc_form="qa_model",
|
||||
original_document_id=None, # go into "new document" branch
|
||||
data_source=data_source,
|
||||
indexing_technique="high_quality",
|
||||
embedding_model=None,
|
||||
embedding_model_provider=None,
|
||||
retrieval_model=None,
|
||||
process_rule=None,
|
||||
duplicate=False,
|
||||
doc_language="en",
|
||||
)
|
||||
|
||||
account = fake_current_user
|
||||
|
||||
# Avoid touching real doc_form logic
|
||||
monkeypatch.setattr("services.dataset_service.DatasetService.check_doc_form", lambda *a, **k: None)
|
||||
# Avoid real DB interactions
|
||||
monkeypatch.setattr("services.dataset_service.db", Mock())
|
||||
|
||||
# Act: this would hit the redis lock, whose __enter__ raises LockNotOwnedError.
|
||||
# Our implementation should catch it and still return (documents, batch).
|
||||
documents, batch = DocumentService.save_document_with_dataset_id(
|
||||
dataset=dataset,
|
||||
knowledge_config=knowledge_config,
|
||||
account=account,
|
||||
)
|
||||
|
||||
# Assert
|
||||
# We mainly care that:
|
||||
# - No exception is raised
|
||||
# - The function returns a sensible tuple
|
||||
assert isinstance(documents, list)
|
||||
assert isinstance(batch, str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Single-segment creation (add_segment)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_segment_ignores_lock_not_owned(
|
||||
monkeypatch,
|
||||
fake_current_user,
|
||||
fake_lock,
|
||||
):
|
||||
# Arrange
|
||||
dataset = create_autospec(Dataset, instance=True)
|
||||
dataset.id = "ds-1"
|
||||
dataset.tenant_id = fake_current_user.current_tenant_id
|
||||
dataset.indexing_technique = "economy" # skip embedding/token calculation branch
|
||||
|
||||
document = create_autospec(Document, instance=True)
|
||||
document.id = "doc-1"
|
||||
document.dataset_id = dataset.id
|
||||
document.word_count = 0
|
||||
document.doc_form = "qa_model"
|
||||
|
||||
# Minimal args required by add_segment
|
||||
args = {
|
||||
"content": "question text",
|
||||
"answer": "answer text",
|
||||
"keywords": ["k1", "k2"],
|
||||
}
|
||||
|
||||
# Avoid real DB operations
|
||||
db_mock = Mock()
|
||||
db_mock.session = Mock()
|
||||
monkeypatch.setattr("services.dataset_service.db", db_mock)
|
||||
monkeypatch.setattr("services.dataset_service.VectorService", Mock())
|
||||
|
||||
# Act
|
||||
result = SegmentService.create_segment(args=args, document=document, dataset=dataset)
|
||||
|
||||
# Assert
|
||||
# Under LockNotOwnedError except, add_segment should swallow the error and return None.
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Multi-segment creation (multi_create_segment)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_multi_create_segment_ignores_lock_not_owned(
|
||||
monkeypatch,
|
||||
fake_current_user,
|
||||
fake_lock,
|
||||
):
|
||||
# Arrange
|
||||
dataset = create_autospec(Dataset, instance=True)
|
||||
dataset.id = "ds-1"
|
||||
dataset.tenant_id = fake_current_user.current_tenant_id
|
||||
dataset.indexing_technique = "economy" # again, skip high_quality path
|
||||
|
||||
document = create_autospec(Document, instance=True)
|
||||
document.id = "doc-1"
|
||||
document.dataset_id = dataset.id
|
||||
document.word_count = 0
|
||||
document.doc_form = "qa_model"
|
||||
4623
api/uv.lock
generated
4623
api/uv.lock
generated
File diff suppressed because it is too large
Load Diff
@ -233,7 +233,7 @@ NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
|
||||
|
||||
# Database type, supported values are `postgresql` and `mysql`
|
||||
DB_TYPE=postgresql
|
||||
# For MySQL, only `root` user is supported for now
|
||||
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=difyai123456
|
||||
DB_HOST=db_postgres
|
||||
@ -1076,10 +1076,24 @@ MAX_TREE_DEPTH=50
|
||||
# ------------------------------
|
||||
# Environment Variables for database Service
|
||||
# ------------------------------
|
||||
|
||||
# The name of the default postgres user.
|
||||
POSTGRES_USER=${DB_USERNAME}
|
||||
# The password for the default postgres user.
|
||||
POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
# The name of the default postgres database.
|
||||
POSTGRES_DB=${DB_DATABASE}
|
||||
# Postgres data directory
|
||||
PGDATA=/var/lib/postgresql/data/pgdata
|
||||
|
||||
# MySQL Default Configuration
|
||||
# The name of the default mysql user.
|
||||
MYSQL_USERNAME=${DB_USERNAME}
|
||||
# The password for the default mysql user.
|
||||
MYSQL_PASSWORD=${DB_PASSWORD}
|
||||
# The name of the default mysql database.
|
||||
MYSQL_DATABASE=${DB_DATABASE}
|
||||
# MySQL data directory
|
||||
MYSQL_HOST_VOLUME=./volumes/mysql/data
|
||||
|
||||
# ------------------------------
|
||||
|
||||
@ -139,9 +139,9 @@ services:
|
||||
- postgresql
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USERNAME:-postgres}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
POSTGRES_DB: ${DB_DATABASE:-dify}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-difyai123456}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-dify}
|
||||
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
|
||||
command: >
|
||||
postgres -c 'max_connections=${POSTGRES_MAX_CONNECTIONS:-100}'
|
||||
@ -161,7 +161,7 @@ services:
|
||||
"-h",
|
||||
"db_postgres",
|
||||
"-U",
|
||||
"${DB_USERNAME:-postgres}",
|
||||
"${PGUSER:-postgres}",
|
||||
"-d",
|
||||
"${DB_DATABASE:-dify}",
|
||||
]
|
||||
@ -176,8 +176,8 @@ services:
|
||||
- mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
MYSQL_DATABASE: ${DB_DATABASE:-dify}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD:-difyai123456}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-dify}
|
||||
command: >
|
||||
--max_connections=1000
|
||||
--innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M}
|
||||
@ -193,7 +193,7 @@ services:
|
||||
"ping",
|
||||
"-u",
|
||||
"root",
|
||||
"-p${DB_PASSWORD:-difyai123456}",
|
||||
"-p${MYSQL_PASSWORD:-difyai123456}",
|
||||
]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
|
||||
@ -9,8 +9,8 @@ services:
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
POSTGRES_DB: ${DB_DATABASE:-dify}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-difyai123456}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-dify}
|
||||
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
|
||||
command: >
|
||||
postgres -c 'max_connections=${POSTGRES_MAX_CONNECTIONS:-100}'
|
||||
@ -32,9 +32,9 @@ services:
|
||||
"-h",
|
||||
"db_postgres",
|
||||
"-U",
|
||||
"${DB_USERNAME:-postgres}",
|
||||
"${PGUSER:-postgres}",
|
||||
"-d",
|
||||
"${DB_DATABASE:-dify}",
|
||||
"${POSTGRES_DB:-dify}",
|
||||
]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
@ -48,8 +48,8 @@ services:
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
MYSQL_DATABASE: ${DB_DATABASE:-dify}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD:-difyai123456}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-dify}
|
||||
command: >
|
||||
--max_connections=1000
|
||||
--innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M}
|
||||
@ -67,7 +67,7 @@ services:
|
||||
"ping",
|
||||
"-u",
|
||||
"root",
|
||||
"-p${DB_PASSWORD:-difyai123456}",
|
||||
"-p${MYSQL_PASSWORD:-difyai123456}",
|
||||
]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
|
||||
@ -455,7 +455,13 @@ x-shared-env: &shared-api-worker-env
|
||||
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
|
||||
ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false}
|
||||
MAX_TREE_DEPTH: ${MAX_TREE_DEPTH:-50}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-${DB_USERNAME}}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-${DB_PASSWORD}}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-${DB_DATABASE}}
|
||||
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
|
||||
MYSQL_USERNAME: ${MYSQL_USERNAME:-${DB_USERNAME}}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-${DB_PASSWORD}}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-${DB_DATABASE}}
|
||||
MYSQL_HOST_VOLUME: ${MYSQL_HOST_VOLUME:-./volumes/mysql/data}
|
||||
SANDBOX_API_KEY: ${SANDBOX_API_KEY:-dify-sandbox}
|
||||
SANDBOX_GIN_MODE: ${SANDBOX_GIN_MODE:-release}
|
||||
@ -768,9 +774,9 @@ services:
|
||||
- postgresql
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USERNAME:-postgres}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
POSTGRES_DB: ${DB_DATABASE:-dify}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-difyai123456}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-dify}
|
||||
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
|
||||
command: >
|
||||
postgres -c 'max_connections=${POSTGRES_MAX_CONNECTIONS:-100}'
|
||||
@ -790,7 +796,7 @@ services:
|
||||
"-h",
|
||||
"db_postgres",
|
||||
"-U",
|
||||
"${DB_USERNAME:-postgres}",
|
||||
"${PGUSER:-postgres}",
|
||||
"-d",
|
||||
"${DB_DATABASE:-dify}",
|
||||
]
|
||||
@ -805,8 +811,8 @@ services:
|
||||
- mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
MYSQL_DATABASE: ${DB_DATABASE:-dify}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD:-difyai123456}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-dify}
|
||||
command: >
|
||||
--max_connections=1000
|
||||
--innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M}
|
||||
@ -822,7 +828,7 @@ services:
|
||||
"ping",
|
||||
"-u",
|
||||
"root",
|
||||
"-p${DB_PASSWORD:-difyai123456}",
|
||||
"-p${MYSQL_PASSWORD:-difyai123456}",
|
||||
]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
# Database Configuration
|
||||
# Database type, supported values are `postgresql` and `mysql`
|
||||
DB_TYPE=postgresql
|
||||
# For MySQL, only `root` user is supported for now
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=difyai123456
|
||||
DB_HOST=db_postgres
|
||||
@ -12,6 +11,11 @@ DB_PORT=5432
|
||||
DB_DATABASE=dify
|
||||
|
||||
# PostgreSQL Configuration
|
||||
POSTGRES_USER=${DB_USERNAME}
|
||||
# The password for the default postgres user.
|
||||
POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
# The name of the default postgres database.
|
||||
POSTGRES_DB=${DB_DATABASE}
|
||||
# postgres data directory
|
||||
PGDATA=/var/lib/postgresql/data/pgdata
|
||||
PGDATA_HOST_VOLUME=./volumes/db/data
|
||||
@ -61,6 +65,11 @@ POSTGRES_STATEMENT_TIMEOUT=0
|
||||
POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT=0
|
||||
|
||||
# MySQL Configuration
|
||||
MYSQL_USERNAME=${DB_USERNAME}
|
||||
# MySQL password
|
||||
MYSQL_PASSWORD=${DB_PASSWORD}
|
||||
# MySQL database name
|
||||
MYSQL_DATABASE=${DB_DATABASE}
|
||||
# MySQL data directory host volume
|
||||
MYSQL_HOST_VOLUME=./volumes/mysql/data
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@ import type { ReactNode } from 'react'
|
||||
import SwrInitializer from '@/app/components/swr-initializer'
|
||||
import { AppContextProvider } from '@/context/app-context'
|
||||
import GA, { GaType } from '@/app/components/base/ga'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import Header from '@/app/components/header'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter'
|
||||
@ -19,7 +18,6 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<AmplitudeProvider />
|
||||
<SwrInitializer>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
|
||||
@ -8,7 +8,7 @@ const PluginList = async () => {
|
||||
return (
|
||||
<PluginPage
|
||||
plugins={<PluginsPanel />}
|
||||
marketplace={<Marketplace locale={locale} pluginTypeSwitchClassName='top-[60px]' showSearchParams={false} />}
|
||||
marketplace={<Marketplace locale={locale} pluginTypeSwitchClassName='top-[60px]' searchBoxAutoAnimate={false} showSearchParams={false} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiGraduationCapFill,
|
||||
@ -22,9 +23,8 @@ import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import EmailChangeModal from './email-change-modal'
|
||||
import { validPassword } from '@/config'
|
||||
|
||||
import { fetchAppList } from '@/service/apps'
|
||||
import type { App } from '@/types/app'
|
||||
import { useAppList } from '@/service/use-apps'
|
||||
|
||||
const titleClassName = `
|
||||
system-sm-semibold text-text-secondary
|
||||
@ -36,7 +36,7 @@ const descriptionClassName = `
|
||||
export default function AccountPage() {
|
||||
const { t } = useTranslation()
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { data: appList } = useAppList({ page: 1, limit: 100, name: '' })
|
||||
const { data: appList } = useSWR({ url: '/apps', params: { page: 1, limit: 100, name: '' } }, fetchAppList)
|
||||
const apps = appList?.data || []
|
||||
const { mutateUserProfile, userProfile } = useAppContext()
|
||||
const { isEducationAccount } = useProviderContext()
|
||||
|
||||
@ -12,7 +12,6 @@ import { useProviderContext } from '@/context/provider-context'
|
||||
import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
import { resetUser } from '@/app/components/base/amplitude/utils'
|
||||
|
||||
export type IAppSelector = {
|
||||
isMobile: boolean
|
||||
@ -29,7 +28,6 @@ export default function AppSelector() {
|
||||
await logout()
|
||||
|
||||
localStorage.removeItem('setup_status')
|
||||
resetUser()
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
router.push('/signin')
|
||||
|
||||
@ -4,7 +4,6 @@ import Header from './header'
|
||||
import SwrInitor from '@/app/components/swr-initializer'
|
||||
import { AppContextProvider } from '@/context/app-context'
|
||||
import GA, { GaType } from '@/app/components/base/ga'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter'
|
||||
import { ProviderContextProvider } from '@/context/provider-context'
|
||||
@ -14,7 +13,6 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<AmplitudeProvider />
|
||||
<SwrInitor>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
|
||||
@ -1,142 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import Authorize from '@/app/components/plugins/plugin-auth/authorize'
|
||||
import Authorized from '@/app/components/plugins/plugin-auth/authorized'
|
||||
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
|
||||
import { usePluginAuth } from '@/app/components/plugins/plugin-auth/hooks/use-plugin-auth'
|
||||
import type { Credential } from '@/app/components/plugins/plugin-auth/types'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { CollectionType } from '@/app/components/tools/types'
|
||||
|
||||
type GroupAuthControlProps = {
|
||||
providerId: string
|
||||
providerName: string
|
||||
providerType: CollectionType
|
||||
credentialId?: string
|
||||
onChange: (credentialId: string) => void
|
||||
}
|
||||
|
||||
const GroupAuthControl: FC<GroupAuthControlProps> = ({
|
||||
providerId,
|
||||
providerName,
|
||||
providerType,
|
||||
credentialId,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isAuthorized,
|
||||
canOAuth,
|
||||
canApiKey,
|
||||
credentials,
|
||||
disabled,
|
||||
invalidPluginCredentialInfo,
|
||||
notAllowCustomCredential,
|
||||
} = usePluginAuth({
|
||||
provider: providerName,
|
||||
providerType,
|
||||
category: AuthCategory.tool,
|
||||
detail: { id: providerId, name: providerName, type: providerType } as any,
|
||||
}, true)
|
||||
|
||||
const extraAuthorizationItems: Credential[] = [
|
||||
{
|
||||
id: '__workspace_default__',
|
||||
name: t('plugin.auth.workspaceDefault'),
|
||||
provider: '',
|
||||
is_default: !credentialId,
|
||||
isWorkspaceDefault: true,
|
||||
},
|
||||
]
|
||||
|
||||
const handleAuthorizationItemClick = useCallback((id: string) => {
|
||||
onChange(id === '__workspace_default__' ? '' : id)
|
||||
}, [onChange])
|
||||
|
||||
const renderTrigger = useCallback((open?: boolean) => {
|
||||
let label = ''
|
||||
let removed = false
|
||||
let unavailable = false
|
||||
let color = 'green'
|
||||
if (!credentialId) {
|
||||
label = t('plugin.auth.workspaceDefault')
|
||||
}
|
||||
else {
|
||||
const credential = credentials.find(c => c.id === credentialId)
|
||||
label = credential ? credential.name : t('plugin.auth.authRemoved')
|
||||
removed = !credential
|
||||
unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise
|
||||
if (removed)
|
||||
color = 'red'
|
||||
else if (unavailable)
|
||||
color = 'gray'
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'h-9',
|
||||
open && 'bg-components-button-secondary-bg-hover',
|
||||
removed && 'text-text-destructive',
|
||||
)}
|
||||
variant='secondary'
|
||||
size='small'
|
||||
>
|
||||
<Indicator className='mr-2' color={color as any} />
|
||||
{label}
|
||||
{
|
||||
unavailable && t('plugin.auth.unavailable')
|
||||
}
|
||||
<RiArrowDownSLine className='ml-0.5 h-4 w-4' />
|
||||
</Button>
|
||||
)
|
||||
}, [credentialId, credentials, t])
|
||||
|
||||
if (!isAuthorized) {
|
||||
return (
|
||||
<Authorize
|
||||
pluginPayload={{
|
||||
provider: providerName,
|
||||
providerType,
|
||||
category: AuthCategory.tool,
|
||||
detail: { id: providerId, name: providerName, type: providerType } as any,
|
||||
}}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Authorized
|
||||
pluginPayload={{
|
||||
provider: providerName,
|
||||
providerType,
|
||||
category: AuthCategory.tool,
|
||||
detail: { id: providerId, name: providerName, type: providerType } as any,
|
||||
}}
|
||||
credentials={credentials}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
disableSetDefault
|
||||
onItemClick={handleAuthorizationItemClick}
|
||||
extraAuthorizationItems={extraAuthorizationItems}
|
||||
showItemSelectedIcon
|
||||
renderTrigger={renderTrigger}
|
||||
selectedCredentialId={credentialId || '__workspace_default__'}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(GroupAuthControl)
|
||||
@ -6,7 +6,6 @@ import { useContext } from 'use-context-selector'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { produce } from 'immer'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiDeleteBinLine,
|
||||
RiEqualizer2Line,
|
||||
RiInformation2Line,
|
||||
@ -25,6 +24,7 @@ import { type Collection, CollectionType } from '@/app/components/tools/types'
|
||||
import { MAX_TOOLS_NUM } from '@/config'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
|
||||
import cn from '@/utils/classnames'
|
||||
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
|
||||
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
|
||||
@ -32,9 +32,8 @@ import { canFindTool } from '@/utils'
|
||||
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { useMittContextSelector } from '@/context/mitt-context'
|
||||
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
|
||||
|
||||
type AgentToolWithMoreInfo = (AgentTool & { icon: any; collection?: Collection; use_end_user_credentials?: boolean; end_user_credential_type?: string }) | null
|
||||
type AgentToolWithMoreInfo = AgentTool & { icon: any; collection?: Collection } | null
|
||||
const AgentTools: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [isShowChooseTool, setIsShowChooseTool] = useState(false)
|
||||
@ -93,13 +92,6 @@ const AgentTools: FC = () => {
|
||||
}
|
||||
|
||||
const [isDeleting, setIsDeleting] = useState<number>(-1)
|
||||
const [expandedProviders, setExpandedProviders] = useState<Record<string, boolean>>({})
|
||||
const toggleProviderExpand = useCallback((providerId: string) => {
|
||||
setExpandedProviders(prev => ({
|
||||
...prev,
|
||||
[providerId]: !prev[providerId],
|
||||
}))
|
||||
}, [])
|
||||
const getToolValue = (tool: ToolDefaultValue) => {
|
||||
return {
|
||||
provider_id: tool.provider_id,
|
||||
@ -110,9 +102,7 @@ const AgentTools: FC = () => {
|
||||
tool_parameters: tool.params,
|
||||
notAuthor: !tool.is_team_authorization,
|
||||
enabled: true,
|
||||
use_end_user_credentials: false,
|
||||
end_user_credential_type: '',
|
||||
} as any
|
||||
}
|
||||
}
|
||||
const handleSelectTool = (tool: ToolDefaultValue) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
@ -148,34 +138,6 @@ const AgentTools: FC = () => {
|
||||
formattingChangedDispatcher()
|
||||
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
|
||||
|
||||
const handleEndUserCredentialChange = useCallback((enabled: boolean) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id)
|
||||
if (tool)
|
||||
(tool as AgentTool).use_end_user_credentials = enabled
|
||||
})
|
||||
setCurrentTool({
|
||||
...currentTool,
|
||||
use_end_user_credentials: enabled,
|
||||
} as any)
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
|
||||
|
||||
const handleEndUserCredentialTypeChange = useCallback((type: string) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id)
|
||||
if (tool)
|
||||
(tool as AgentTool).end_user_credential_type = type
|
||||
})
|
||||
setCurrentTool({
|
||||
...currentTool,
|
||||
end_user_credential_type: type,
|
||||
} as any)
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel
|
||||
@ -215,307 +177,134 @@ const AgentTools: FC = () => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
{Object.values(
|
||||
tools.reduce((acc, item, idx) => {
|
||||
const key = item.provider_id
|
||||
if (!acc[key]) {
|
||||
acc[key] = {
|
||||
providerId: item.provider_id,
|
||||
providerName: getProviderShowName(item) || '',
|
||||
icon: item.icon,
|
||||
providerType: item.provider_type,
|
||||
tools: [] as (AgentTool & { __index: number })[],
|
||||
}
|
||||
}
|
||||
acc[key].tools.push({ ...item, __index: idx })
|
||||
return acc
|
||||
}, {} as Record<string, { providerId: string; providerName: string; providerType: CollectionType; icon: any; tools: (AgentTool & { __index: number })[] }>),
|
||||
).map(group => (
|
||||
<div
|
||||
key={group.providerId}
|
||||
className='rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-2 shadow-xs'
|
||||
<div className='grid grid-cols-1 flex-wrap items-center justify-between gap-1 2xl:grid-cols-2'>
|
||||
{tools.map((item: AgentTool & { icon: any; collection?: Collection }, index) => (
|
||||
<div key={index}
|
||||
className={cn(
|
||||
'cursor group relative flex w-full items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
|
||||
isDeleting === index && 'border-state-destructive-border hover:bg-state-destructive-hover',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='flex cursor-pointer items-center gap-2 px-1'
|
||||
onClick={() => toggleProviderExpand(group.providerId)}
|
||||
>
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-text-tertiary transition-transform',
|
||||
!expandedProviders[group.providerId] && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
{typeof group.icon === 'string'
|
||||
? <div className='h-5 w-5 shrink-0 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${group.icon})` }} />
|
||||
: <AppIcon className='shrink-0 rounded-md' size='xs' icon={group.icon?.content} background={group.icon?.background} />}
|
||||
<div className='system-sm-semibold truncate text-text-secondary'>{group.providerName}</div>
|
||||
<div className='ml-auto flex shrink-0 items-center gap-2'>
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{group.tools.filter(tool => tool.enabled).length}/{group.tools.length} {t('appDebug.agent.tools.enabled')}
|
||||
<div className='flex w-0 grow items-center'>
|
||||
{item.isDeleted && <DefaultToolIcon className='h-5 w-5' />}
|
||||
{!item.isDeleted && (
|
||||
<div className={cn((item.notAuthor || !item.enabled) && 'shrink-0 opacity-50')}>
|
||||
{typeof item.icon === 'string' && <div className='h-5 w-5 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${item.icon})` }} />}
|
||||
{typeof item.icon !== 'string' && <AppIcon className='rounded-md' size='xs' icon={item.icon?.content} background={item.icon?.background} />}
|
||||
</div>
|
||||
{group.tools.every(tool => tool.notAuthor) && (
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='small'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const first = group.tools[0]
|
||||
setCurrentTool(first as any)
|
||||
setIsShowSettingTool(true)
|
||||
}}
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-regular ml-1.5 flex w-0 grow items-center truncate',
|
||||
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
|
||||
)}
|
||||
>
|
||||
<span className='system-xs-medium pr-1.5 text-text-secondary'>{getProviderShowName(item)}</span>
|
||||
<span className='text-text-tertiary'>{item.tool_label}</span>
|
||||
{!item.isDeleted && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]'>
|
||||
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
|
||||
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
|
||||
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='h-4 w-4'>
|
||||
<div className='ml-0.5 hidden group-hover:inline-block'>
|
||||
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='ml-1 flex shrink-0 items-center'>
|
||||
{item.isDeleted && (
|
||||
<div className='mr-2 flex items-center'>
|
||||
<Tooltip
|
||||
popupContent={t('tools.toolRemoved')}
|
||||
>
|
||||
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
|
||||
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!item.isDeleted && (
|
||||
<div className='mr-2 hidden items-center gap-1 group-hover:flex'>
|
||||
{!item.notAuthor && (
|
||||
<Tooltip
|
||||
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
|
||||
>
|
||||
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
|
||||
setCurrentTool(item)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(item.isDeleted && 'opacity-50')}>
|
||||
{!item.notAuthor && (
|
||||
<Switch
|
||||
defaultValue={item.isDeleted ? false : item.enabled}
|
||||
disabled={item.isDeleted}
|
||||
size='md'
|
||||
onChange={(enabled) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
(draft.agentConfig.tools[index] as any).enabled = enabled
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}} />
|
||||
)}
|
||||
{item.notAuthor && (
|
||||
<Button variant='secondary' size='small' onClick={() => {
|
||||
setCurrentTool(item)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
{t('tools.notAuthorized')}
|
||||
<Indicator className='ml-2' color='orange' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('space-y-1', expandedProviders[group.providerId] ? 'mt-1' : 'hidden')}>
|
||||
{group.tools.map(item => (
|
||||
<div
|
||||
key={`${item.provider_id}-${item.tool_name}`}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center justify-between rounded-lg pl-[21px] pr-2 hover:bg-state-base-hover',
|
||||
isDeleting === item.__index && 'border border-state-destructive-border hover:bg-state-destructive-hover',
|
||||
)}
|
||||
>
|
||||
<div className='flex w-0 grow items-center'>
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-regular flex w-0 grow items-center truncate border-l-2 border-divider-subtle pl-4',
|
||||
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
|
||||
)}
|
||||
>
|
||||
<span className='system-xs-medium pr-1.5 text-text-secondary'>{item.tool_label}</span>
|
||||
<span className='text-text-tertiary'>{item.tool_name}</span>
|
||||
{!item.isDeleted && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]'>
|
||||
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
|
||||
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
|
||||
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='h-4 w-4'>
|
||||
<div className='ml-0.5 hidden group-hover:inline-block'>
|
||||
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex shrink-0 items-center space-x-2'>
|
||||
{item.isDeleted && (
|
||||
<div className='mr-2 flex items-center'>
|
||||
<Tooltip
|
||||
popupContent={t('tools.toolRemoved')}
|
||||
>
|
||||
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
|
||||
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(item.__index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(item.__index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!item.isDeleted && (
|
||||
<div className='pointer-events-none mr-2 flex items-center gap-1 opacity-0 transition-opacity duration-150 group-hover:pointer-events-auto group-hover:opacity-100'>
|
||||
{!item.notAuthor && (
|
||||
<Tooltip
|
||||
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
|
||||
>
|
||||
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
|
||||
setCurrentTool(item as any)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(item.__index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(item.__index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(item.isDeleted && 'opacity-50')}>
|
||||
{!item.notAuthor && (
|
||||
<Switch
|
||||
defaultValue={item.isDeleted ? false : item.enabled}
|
||||
disabled={item.isDeleted}
|
||||
size='md'
|
||||
onChange={(enabled) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
(draft.agentConfig.tools[item.__index] as any).enabled = enabled
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='grid grid-cols-1 flex-wrap items-center justify-between gap-1 2xl:grid-cols-2'>
|
||||
{tools.map((item: AgentTool & { icon: any; collection?: Collection }, index) => (
|
||||
<div key={index}
|
||||
className={cn(
|
||||
'cursor group relative flex w-full items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
|
||||
isDeleting === index && 'border-state-destructive-border hover:bg-state-destructive-hover',
|
||||
)}
|
||||
>
|
||||
<div className='flex w-0 grow items-center'>
|
||||
{item.isDeleted && <DefaultToolIcon className='h-5 w-5' />}
|
||||
{!item.isDeleted && (
|
||||
<div className={cn((item.notAuthor || !item.enabled) && 'shrink-0 opacity-50')}>
|
||||
{typeof item.icon === 'string' && <div className='h-5 w-5 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${item.icon})` }} />}
|
||||
{typeof item.icon !== 'string' && <AppIcon className='rounded-md' size='xs' icon={item.icon?.content} background={item.icon?.background} />}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-regular ml-1.5 flex w-0 grow items-center truncate',
|
||||
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
|
||||
)}
|
||||
>
|
||||
<span className='system-xs-medium pr-1.5 text-text-secondary'>{getProviderShowName(item)}</span>
|
||||
<span className='text-text-tertiary'>{item.tool_label}</span>
|
||||
{!item.isDeleted && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]'>
|
||||
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
|
||||
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
|
||||
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='h-4 w-4'>
|
||||
<div className='ml-0.5 hidden group-hover:inline-block'>
|
||||
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='ml-1 flex shrink-0 items-center'>
|
||||
{item.isDeleted && (
|
||||
<div className='mr-2 flex items-center'>
|
||||
<Tooltip
|
||||
popupContent={t('tools.toolRemoved')}
|
||||
>
|
||||
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
|
||||
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!item.isDeleted && (
|
||||
<div className='mr-2 hidden items-center gap-1 group-hover:flex'>
|
||||
{!item.notAuthor && (
|
||||
<Tooltip
|
||||
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
|
||||
needsDelay={false}
|
||||
>
|
||||
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
|
||||
setCurrentTool(item)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(item.isDeleted && 'opacity-50')}>
|
||||
{!item.notAuthor && (
|
||||
<Switch
|
||||
defaultValue={item.isDeleted ? false : item.enabled}
|
||||
disabled={item.isDeleted}
|
||||
size='md'
|
||||
onChange={(enabled) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
(draft.agentConfig.tools[index] as any).enabled = enabled
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}} />
|
||||
)}
|
||||
{item.notAuthor && (
|
||||
<Button variant='secondary' size='small' onClick={() => {
|
||||
setCurrentTool(item)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
{t('tools.notAuthorized')}
|
||||
<Indicator className='ml-2' color='orange' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
</div >
|
||||
</Panel >
|
||||
{isShowSettingTool && (
|
||||
<SettingBuiltInTool
|
||||
toolName={currentTool?.tool_name as string}
|
||||
@ -526,10 +315,6 @@ const AgentTools: FC = () => {
|
||||
onHide={() => setIsShowSettingTool(false)}
|
||||
credentialId={currentTool?.credential_id}
|
||||
onAuthorizationItemClick={handleAuthorizationItemClick}
|
||||
useEndUserCredentialEnabled={currentTool?.use_end_user_credentials}
|
||||
endUserCredentialType={currentTool?.end_user_credential_type}
|
||||
onEndUserCredentialChange={handleEndUserCredentialChange}
|
||||
onEndUserCredentialTypeChange={handleEndUserCredentialTypeChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -42,10 +42,6 @@ type Props = {
|
||||
onSave?: (value: Record<string, any>) => void
|
||||
credentialId?: string
|
||||
onAuthorizationItemClick?: (id: string) => void
|
||||
useEndUserCredentialEnabled?: boolean
|
||||
endUserCredentialType?: string
|
||||
onEndUserCredentialChange?: (enabled: boolean) => void
|
||||
onEndUserCredentialTypeChange?: (type: string) => void
|
||||
}
|
||||
|
||||
const SettingBuiltInTool: FC<Props> = ({
|
||||
@ -60,10 +56,6 @@ const SettingBuiltInTool: FC<Props> = ({
|
||||
onSave,
|
||||
credentialId,
|
||||
onAuthorizationItemClick,
|
||||
useEndUserCredentialEnabled,
|
||||
endUserCredentialType,
|
||||
onEndUserCredentialChange,
|
||||
onEndUserCredentialTypeChange,
|
||||
}) => {
|
||||
const { locale } = useContext(I18n)
|
||||
const language = getLanguage(locale)
|
||||
@ -228,10 +220,6 @@ const SettingBuiltInTool: FC<Props> = ({
|
||||
}}
|
||||
credentialId={credentialId}
|
||||
onAuthorizationItemClick={onAuthorizationItemClick}
|
||||
useEndUserCredentialEnabled={useEndUserCredentialEnabled}
|
||||
endUserCredentialType={endUserCredentialType}
|
||||
onEndUserCredentialChange={onEndUserCredentialChange}
|
||||
onEndUserCredentialTypeChange={onEndUserCredentialTypeChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -52,6 +52,7 @@ export type IGetAutomaticResProps = {
|
||||
editorId?: string
|
||||
currentPrompt?: string
|
||||
isBasicMode?: boolean
|
||||
hideTryIt?: boolean
|
||||
}
|
||||
|
||||
const TryLabel: FC<{
|
||||
@ -80,6 +81,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
currentPrompt,
|
||||
isBasicMode,
|
||||
onFinished,
|
||||
hideTryIt,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const localModel = localStorage.getItem('auto-gen-model')
|
||||
@ -305,7 +307,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
hideDebugWithMultipleModel
|
||||
/>
|
||||
</div>
|
||||
{isBasicMode && (
|
||||
{isBasicMode && !hideTryIt && (
|
||||
<div className='mt-4'>
|
||||
<div className='flex items-center'>
|
||||
<div className='mr-3 shrink-0 text-xs font-semibold uppercase leading-[18px] text-text-tertiary'>{t('appDebug.generate.tryIt')}</div>
|
||||
|
||||
@ -28,7 +28,6 @@ import Input from '@/app/components/base/input'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { DSLImportMode } from '@/models/app'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
type AppsProps = {
|
||||
onSuccess?: () => void
|
||||
@ -142,15 +141,6 @@ const Apps = ({
|
||||
icon_background,
|
||||
description,
|
||||
})
|
||||
|
||||
// Track app creation from template
|
||||
trackEvent('create_app_with_template', {
|
||||
app_mode: mode,
|
||||
template_id: currApp?.app.id,
|
||||
template_name: currApp?.app.name,
|
||||
description,
|
||||
})
|
||||
|
||||
setIsShowCreateModal(false)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
|
||||
@ -30,7 +30,6 @@ import { getRedirection } from '@/utils/app-redirection'
|
||||
import FullScreenModal from '@/app/components/base/fullscreen-modal'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
type CreateAppProps = {
|
||||
onSuccess: () => void
|
||||
@ -83,13 +82,6 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
|
||||
mode: appMode,
|
||||
})
|
||||
|
||||
// Track app creation success
|
||||
trackEvent('create_app', {
|
||||
app_mode: appMode,
|
||||
description,
|
||||
})
|
||||
|
||||
notify({ type: 'success', message: t('app.newApp.appCreated') })
|
||||
onSuccess()
|
||||
onClose()
|
||||
|
||||
@ -28,7 +28,6 @@ import { getRedirection } from '@/utils/app-redirection'
|
||||
import cn from '@/utils/classnames'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import { noop } from 'lodash-es'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
type CreateFromDSLModalProps = {
|
||||
show: boolean
|
||||
@ -113,13 +112,6 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
return
|
||||
const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response
|
||||
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
|
||||
// Track app creation from DSL import
|
||||
trackEvent('create_app_with_dsl', {
|
||||
app_mode,
|
||||
creation_method: currentTab === CreateFromDSLModalTab.FROM_FILE ? 'dsl_file' : 'dsl_url',
|
||||
has_warnings: status === DSLImportStatus.COMPLETED_WITH_WARNINGS,
|
||||
})
|
||||
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
if (onClose)
|
||||
|
||||
@ -3,6 +3,7 @@ import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import useSWR from 'swr'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
import { get } from 'lodash-es'
|
||||
@ -12,20 +13,7 @@ import { formatNumber } from '@/utils/format'
|
||||
import Basic from '@/app/components/app-sidebar/basic'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import type { AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyMessagesResponse, AppTokenCostsResponse } from '@/models/app'
|
||||
import {
|
||||
useAppAverageResponseTime,
|
||||
useAppAverageSessionInteractions,
|
||||
useAppDailyConversations,
|
||||
useAppDailyEndUsers,
|
||||
useAppDailyMessages,
|
||||
useAppSatisfactionRate,
|
||||
useAppTokenCosts,
|
||||
useAppTokensPerSecond,
|
||||
useWorkflowAverageInteractions,
|
||||
useWorkflowDailyConversations,
|
||||
useWorkflowDailyTerminals,
|
||||
useWorkflowTokenCosts,
|
||||
} from '@/service/use-apps'
|
||||
import { getAppDailyConversations, getAppDailyEndUsers, getAppDailyMessages, getAppStatistics, getAppTokenCosts, getWorkflowDailyConversations } from '@/service/apps'
|
||||
const valueFormatter = (v: string | number) => v
|
||||
|
||||
const COLOR_TYPE_MAP = {
|
||||
@ -284,8 +272,8 @@ const getDefaultChartData = ({ start, end, key = 'count' }: { start: string; end
|
||||
|
||||
export const MessagesChart: FC<IBizChartProps> = ({ id, period }) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: response, isLoading } = useAppDailyMessages(id, period.query)
|
||||
if (isLoading || !response)
|
||||
const { data: response } = useSWR({ url: `/apps/${id}/statistics/daily-messages`, params: period.query }, getAppDailyMessages)
|
||||
if (!response)
|
||||
return <Loading />
|
||||
const noDataFlag = !response.data || response.data.length === 0
|
||||
return <Chart
|
||||
@ -298,8 +286,8 @@ export const MessagesChart: FC<IBizChartProps> = ({ id, period }) => {
|
||||
|
||||
export const ConversationsChart: FC<IBizChartProps> = ({ id, period }) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: response, isLoading } = useAppDailyConversations(id, period.query)
|
||||
if (isLoading || !response)
|
||||
const { data: response } = useSWR({ url: `/apps/${id}/statistics/daily-conversations`, params: period.query }, getAppDailyConversations)
|
||||
if (!response)
|
||||
return <Loading />
|
||||
const noDataFlag = !response.data || response.data.length === 0
|
||||
return <Chart
|
||||
@ -313,8 +301,8 @@ export const ConversationsChart: FC<IBizChartProps> = ({ id, period }) => {
|
||||
export const EndUsersChart: FC<IBizChartProps> = ({ id, period }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data: response, isLoading } = useAppDailyEndUsers(id, period.query)
|
||||
if (isLoading || !response)
|
||||
const { data: response } = useSWR({ url: `/apps/${id}/statistics/daily-end-users`, id, params: period.query }, getAppDailyEndUsers)
|
||||
if (!response)
|
||||
return <Loading />
|
||||
const noDataFlag = !response.data || response.data.length === 0
|
||||
return <Chart
|
||||
@ -327,8 +315,8 @@ export const EndUsersChart: FC<IBizChartProps> = ({ id, period }) => {
|
||||
|
||||
export const AvgSessionInteractions: FC<IBizChartProps> = ({ id, period }) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: response, isLoading } = useAppAverageSessionInteractions(id, period.query)
|
||||
if (isLoading || !response)
|
||||
const { data: response } = useSWR({ url: `/apps/${id}/statistics/average-session-interactions`, params: period.query }, getAppStatistics)
|
||||
if (!response)
|
||||
return <Loading />
|
||||
const noDataFlag = !response.data || response.data.length === 0
|
||||
return <Chart
|
||||
@ -343,8 +331,8 @@ export const AvgSessionInteractions: FC<IBizChartProps> = ({ id, period }) => {
|
||||
|
||||
export const AvgResponseTime: FC<IBizChartProps> = ({ id, period }) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: response, isLoading } = useAppAverageResponseTime(id, period.query)
|
||||
if (isLoading || !response)
|
||||
const { data: response } = useSWR({ url: `/apps/${id}/statistics/average-response-time`, params: period.query }, getAppStatistics)
|
||||
if (!response)
|
||||
return <Loading />
|
||||
const noDataFlag = !response.data || response.data.length === 0
|
||||
return <Chart
|
||||
@ -360,8 +348,8 @@ export const AvgResponseTime: FC<IBizChartProps> = ({ id, period }) => {
|
||||
|
||||
export const TokenPerSecond: FC<IBizChartProps> = ({ id, period }) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: response, isLoading } = useAppTokensPerSecond(id, period.query)
|
||||
if (isLoading || !response)
|
||||
const { data: response } = useSWR({ url: `/apps/${id}/statistics/tokens-per-second`, params: period.query }, getAppStatistics)
|
||||
if (!response)
|
||||
return <Loading />
|
||||
const noDataFlag = !response.data || response.data.length === 0
|
||||
return <Chart
|
||||
@ -378,8 +366,8 @@ export const TokenPerSecond: FC<IBizChartProps> = ({ id, period }) => {
|
||||
|
||||
export const UserSatisfactionRate: FC<IBizChartProps> = ({ id, period }) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: response, isLoading } = useAppSatisfactionRate(id, period.query)
|
||||
if (isLoading || !response)
|
||||
const { data: response } = useSWR({ url: `/apps/${id}/statistics/user-satisfaction-rate`, params: period.query }, getAppStatistics)
|
||||
if (!response)
|
||||
return <Loading />
|
||||
const noDataFlag = !response.data || response.data.length === 0
|
||||
return <Chart
|
||||
@ -396,8 +384,8 @@ export const UserSatisfactionRate: FC<IBizChartProps> = ({ id, period }) => {
|
||||
export const CostChart: FC<IBizChartProps> = ({ id, period }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data: response, isLoading } = useAppTokenCosts(id, period.query)
|
||||
if (isLoading || !response)
|
||||
const { data: response } = useSWR({ url: `/apps/${id}/statistics/token-costs`, params: period.query }, getAppTokenCosts)
|
||||
if (!response)
|
||||
return <Loading />
|
||||
const noDataFlag = !response.data || response.data.length === 0
|
||||
return <Chart
|
||||
@ -410,8 +398,8 @@ export const CostChart: FC<IBizChartProps> = ({ id, period }) => {
|
||||
|
||||
export const WorkflowMessagesChart: FC<IBizChartProps> = ({ id, period }) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: response, isLoading } = useWorkflowDailyConversations(id, period.query)
|
||||
if (isLoading || !response)
|
||||
const { data: response } = useSWR({ url: `/apps/${id}/workflow/statistics/daily-conversations`, params: period.query }, getWorkflowDailyConversations)
|
||||
if (!response)
|
||||
return <Loading />
|
||||
const noDataFlag = !response.data || response.data.length === 0
|
||||
return <Chart
|
||||
@ -426,8 +414,8 @@ export const WorkflowMessagesChart: FC<IBizChartProps> = ({ id, period }) => {
|
||||
export const WorkflowDailyTerminalsChart: FC<IBizChartProps> = ({ id, period }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data: response, isLoading } = useWorkflowDailyTerminals(id, period.query)
|
||||
if (isLoading || !response)
|
||||
const { data: response } = useSWR({ url: `/apps/${id}/workflow/statistics/daily-terminals`, id, params: period.query }, getAppDailyEndUsers)
|
||||
if (!response)
|
||||
return <Loading />
|
||||
const noDataFlag = !response.data || response.data.length === 0
|
||||
return <Chart
|
||||
@ -441,8 +429,8 @@ export const WorkflowDailyTerminalsChart: FC<IBizChartProps> = ({ id, period })
|
||||
export const WorkflowCostChart: FC<IBizChartProps> = ({ id, period }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data: response, isLoading } = useWorkflowTokenCosts(id, period.query)
|
||||
if (isLoading || !response)
|
||||
const { data: response } = useSWR({ url: `/apps/${id}/workflow/statistics/token-costs`, params: period.query }, getAppTokenCosts)
|
||||
if (!response)
|
||||
return <Loading />
|
||||
const noDataFlag = !response.data || response.data.length === 0
|
||||
return <Chart
|
||||
@ -455,8 +443,8 @@ export const WorkflowCostChart: FC<IBizChartProps> = ({ id, period }) => {
|
||||
|
||||
export const AvgUserInteractions: FC<IBizChartProps> = ({ id, period }) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: response, isLoading } = useWorkflowAverageInteractions(id, period.query)
|
||||
if (isLoading || !response)
|
||||
const { data: response } = useSWR({ url: `/apps/${id}/workflow/statistics/average-app-interactions`, params: period.query }, getAppStatistics)
|
||||
if (!response)
|
||||
return <Loading />
|
||||
const noDataFlag = !response.data || response.data.length === 0
|
||||
return <Chart
|
||||
|
||||
@ -8,7 +8,6 @@ import quarterOfYear from 'dayjs/plugin/quarterOfYear'
|
||||
import type { QueryParam } from './index'
|
||||
import Chip from '@/app/components/base/chip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { trackEvent } from '@/app/components/base/amplitude/utils'
|
||||
dayjs.extend(quarterOfYear)
|
||||
|
||||
const today = dayjs()
|
||||
@ -38,9 +37,6 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps)
|
||||
value={queryParams.status || 'all'}
|
||||
onSelect={(item) => {
|
||||
setQueryParams({ ...queryParams, status: item.value as string })
|
||||
trackEvent('workflow_log_filter_status_selected', {
|
||||
workflow_log_filter_status: item.value as string,
|
||||
})
|
||||
}}
|
||||
onClear={() => setQueryParams({ ...queryParams, status: 'all' })}
|
||||
items={[{ value: 'all', name: 'All' },
|
||||
|
||||
@ -23,7 +23,7 @@ const Empty = () => {
|
||||
return (
|
||||
<>
|
||||
<DefaultCards />
|
||||
<div className='pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-gradient-to-t from-background-body to-transparent'>
|
||||
<div className='absolute inset-0 z-20 flex items-center justify-center bg-gradient-to-t from-background-body to-transparent pointer-events-none'>
|
||||
<span className='system-md-medium text-text-tertiary'>
|
||||
{t('app.newApp.noAppsFound')}
|
||||
</span>
|
||||
|
||||
@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
useRouter,
|
||||
} from 'next/navigation'
|
||||
import useSWRInfinite from 'swr/infinite'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import {
|
||||
@ -18,6 +19,8 @@ import AppCard from './app-card'
|
||||
import NewAppCard from './new-app-card'
|
||||
import useAppsQueryState from './hooks/use-apps-query-state'
|
||||
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import { fetchAppList } from '@/service/apps'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
@ -32,7 +35,6 @@ import Empty from './empty'
|
||||
import Footer from './footer'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { useInfiniteAppList } from '@/service/use-apps'
|
||||
|
||||
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
|
||||
ssr: false,
|
||||
@ -41,6 +43,30 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const getKey = (
|
||||
pageIndex: number,
|
||||
previousPageData: AppListResponse,
|
||||
activeTab: string,
|
||||
isCreatedByMe: boolean,
|
||||
tags: string[],
|
||||
keywords: string,
|
||||
) => {
|
||||
if (!pageIndex || previousPageData.has_more) {
|
||||
const params: any = { url: 'apps', params: { page: pageIndex + 1, limit: 30, name: keywords, is_created_by_me: isCreatedByMe } }
|
||||
|
||||
if (activeTab !== 'all')
|
||||
params.params.mode = activeTab
|
||||
else
|
||||
delete params.params.mode
|
||||
|
||||
if (tags.length)
|
||||
params.params.tag_ids = tags
|
||||
|
||||
return params
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const List = () => {
|
||||
const { t } = useTranslation()
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
@ -76,24 +102,16 @@ const List = () => {
|
||||
enabled: isCurrentWorkspaceEditor,
|
||||
})
|
||||
|
||||
const appListQueryParams = {
|
||||
page: 1,
|
||||
limit: 30,
|
||||
name: searchKeywords,
|
||||
tag_ids: tagIDs,
|
||||
is_created_by_me: isCreatedByMe,
|
||||
...(activeTab !== 'all' ? { mode: activeTab as AppModeEnum } : {}),
|
||||
}
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
error,
|
||||
refetch,
|
||||
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
|
||||
const { data, isLoading, error, setSize, mutate } = useSWRInfinite(
|
||||
(pageIndex: number, previousPageData: AppListResponse) => getKey(pageIndex, previousPageData, activeTab, isCreatedByMe, tagIDs, searchKeywords),
|
||||
fetchAppList,
|
||||
{
|
||||
revalidateFirstPage: true,
|
||||
shouldRetryOnError: false,
|
||||
dedupingInterval: 500,
|
||||
errorRetryCount: 3,
|
||||
},
|
||||
)
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const options = [
|
||||
@ -108,9 +126,9 @@ const List = () => {
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
|
||||
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
|
||||
refetch()
|
||||
mutate()
|
||||
}
|
||||
}, [refetch])
|
||||
}, [mutate, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrentWorkspaceDatasetOperator)
|
||||
@ -118,9 +136,7 @@ const List = () => {
|
||||
}, [router, isCurrentWorkspaceDatasetOperator])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrentWorkspaceDatasetOperator)
|
||||
return
|
||||
const hasMore = hasNextPage ?? true
|
||||
const hasMore = data?.at(-1)?.has_more ?? true
|
||||
let observer: IntersectionObserver | undefined
|
||||
|
||||
if (error) {
|
||||
@ -135,8 +151,8 @@ const List = () => {
|
||||
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
|
||||
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
|
||||
fetchNextPage()
|
||||
if (entries[0].isIntersecting && !isLoading && !error && hasMore)
|
||||
setSize((size: number) => size + 1)
|
||||
}, {
|
||||
root: containerRef.current,
|
||||
rootMargin: `${dynamicMargin}px`,
|
||||
@ -145,7 +161,7 @@ const List = () => {
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
|
||||
}, [isLoading, setSize, data, error])
|
||||
|
||||
const { run: handleSearch } = useDebounceFn(() => {
|
||||
setSearchKeywords(keywords)
|
||||
@ -169,9 +185,6 @@ const List = () => {
|
||||
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
|
||||
}, [isCreatedByMe, setQuery])
|
||||
|
||||
const pages = data?.pages ?? []
|
||||
const hasAnyApp = (pages[0]?.total ?? 0) > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'>
|
||||
@ -204,17 +217,17 @@ const List = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasAnyApp
|
||||
{(data && data[0].total > 0)
|
||||
? <div className='relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'>
|
||||
{isCurrentWorkspaceEditor
|
||||
&& <NewAppCard ref={newAppCardRef} onSuccess={refetch} selectedAppType={activeTab} />}
|
||||
{pages.map(({ data: apps }) => apps.map(app => (
|
||||
<AppCard key={app.id} app={app} onRefresh={refetch} />
|
||||
&& <NewAppCard ref={newAppCardRef} onSuccess={mutate} selectedAppType={activeTab} />}
|
||||
{data.map(({ data: apps }) => apps.map(app => (
|
||||
<AppCard key={app.id} app={app} onRefresh={mutate} />
|
||||
)))}
|
||||
</div>
|
||||
: <div className='relative grid grow grid-cols-1 content-start gap-4 overflow-hidden px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'>
|
||||
{isCurrentWorkspaceEditor
|
||||
&& <NewAppCard ref={newAppCardRef} className='z-10' onSuccess={refetch} selectedAppType={activeTab} />}
|
||||
&& <NewAppCard ref={newAppCardRef} className='z-10' onSuccess={mutate} selectedAppType={activeTab} />}
|
||||
<Empty />
|
||||
</div>}
|
||||
|
||||
@ -248,7 +261,7 @@ const List = () => {
|
||||
onSuccess={() => {
|
||||
setShowCreateFromDSLModal(false)
|
||||
setDroppedDSLFile(undefined)
|
||||
refetch()
|
||||
mutate()
|
||||
}}
|
||||
droppedFile={droppedDSLFile}
|
||||
/>
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import * as amplitude from '@amplitude/analytics-browser'
|
||||
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
|
||||
export type IAmplitudeProps = {
|
||||
apiKey?: string
|
||||
sessionReplaySampleRate?: number
|
||||
}
|
||||
|
||||
const AmplitudeProvider: FC<IAmplitudeProps> = ({
|
||||
apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY ?? '',
|
||||
sessionReplaySampleRate = 1,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
// Only enable in Saas edition
|
||||
if (!IS_CLOUD_EDITION)
|
||||
return
|
||||
|
||||
// Initialize Amplitude
|
||||
amplitude.init(apiKey, {
|
||||
defaultTracking: {
|
||||
sessions: true,
|
||||
pageViews: true,
|
||||
formInteractions: true,
|
||||
fileDownloads: true,
|
||||
},
|
||||
// Enable debug logs in development environment
|
||||
logLevel: amplitude.Types.LogLevel.Warn,
|
||||
})
|
||||
|
||||
// Add Session Replay plugin
|
||||
const sessionReplay = sessionReplayPlugin({
|
||||
sampleRate: sessionReplaySampleRate,
|
||||
})
|
||||
amplitude.add(sessionReplay)
|
||||
}, [])
|
||||
|
||||
// This is a client component that renders nothing
|
||||
return null
|
||||
}
|
||||
|
||||
export default React.memo(AmplitudeProvider)
|
||||
@ -1,2 +0,0 @@
|
||||
export { default } from './AmplitudeProvider'
|
||||
export { resetUser, setUserId, setUserProperties, trackEvent } from './utils'
|
||||
@ -1,37 +0,0 @@
|
||||
import * as amplitude from '@amplitude/analytics-browser'
|
||||
|
||||
/**
|
||||
* Track custom event
|
||||
* @param eventName Event name
|
||||
* @param eventProperties Event properties (optional)
|
||||
*/
|
||||
export const trackEvent = (eventName: string, eventProperties?: Record<string, any>) => {
|
||||
amplitude.track(eventName, eventProperties)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user ID
|
||||
* @param userId User ID
|
||||
*/
|
||||
export const setUserId = (userId: string) => {
|
||||
amplitude.setUserId(userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user properties
|
||||
* @param properties User properties
|
||||
*/
|
||||
export const setUserProperties = (properties: Record<string, any>) => {
|
||||
const identifyEvent = new amplitude.Identify()
|
||||
Object.entries(properties).forEach(([key, value]) => {
|
||||
identifyEvent.set(key, value)
|
||||
})
|
||||
amplitude.identify(identifyEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset user (e.g., when user logs out)
|
||||
*/
|
||||
export const resetUser = () => {
|
||||
amplitude.reset()
|
||||
}
|
||||
@ -24,10 +24,6 @@ import cn from '@/utils/classnames'
|
||||
import type { FileEntity } from '../../file-uploader/types'
|
||||
import { formatBooleanInputs } from '@/utils/model-config'
|
||||
import Avatar from '../../avatar'
|
||||
import ServiceConnectionPanel from '@/app/components/base/service-connection-panel'
|
||||
import type { AuthType, ServiceConnectionItem as ServiceConnectionItemType } from '@/app/components/base/service-connection-panel'
|
||||
import { Notion } from '@/app/components/base/icons/src/public/common'
|
||||
import { Google } from '@/app/components/base/icons/src/public/plugins'
|
||||
|
||||
const ChatWrapper = () => {
|
||||
const {
|
||||
@ -171,53 +167,6 @@ const ChatWrapper = () => {
|
||||
|
||||
const [collapsed, setCollapsed] = useState(!!currentConversationId)
|
||||
|
||||
// Demo: Service connection state
|
||||
const [serviceConnections, setServiceConnections] = useState<ServiceConnectionItemType[]>([
|
||||
{
|
||||
id: 'notion',
|
||||
name: 'Notion Page Search',
|
||||
icon: <Notion className="h-6 w-6" />,
|
||||
authType: 'oauth',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'gmail',
|
||||
name: 'Gmail Tools',
|
||||
icon: <img src="https://www.gstatic.com/images/branding/product/1x/gmail_2020q4_32dp.png" alt="Gmail" className="h-6 w-6" />,
|
||||
authType: 'oauth',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'youtube',
|
||||
name: 'YouTube Data Upload',
|
||||
icon: <img src="https://www.youtube.com/s/desktop/f506bd45/img/favicon_32x32.png" alt="YouTube" className="h-6 w-6" />,
|
||||
authType: 'oauth',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'google-serp',
|
||||
name: 'Google SerpApi Search',
|
||||
icon: <Google className="h-6 w-6" />,
|
||||
authType: 'api_key',
|
||||
status: 'pending',
|
||||
},
|
||||
])
|
||||
|
||||
const [showServiceConnection, setShowServiceConnection] = useState(true)
|
||||
|
||||
const handleServiceConnect = useCallback((serviceId: string, _authType: AuthType) => {
|
||||
// Demo: 模拟连接成功
|
||||
setServiceConnections(prev => prev.map(service =>
|
||||
service.id === serviceId
|
||||
? { ...service, status: 'connected' as const }
|
||||
: service,
|
||||
))
|
||||
}, [])
|
||||
|
||||
const handleServiceContinue = useCallback(() => {
|
||||
setShowServiceConnection(false)
|
||||
}, [])
|
||||
|
||||
const chatNode = useMemo(() => {
|
||||
if (allInputsHidden || !inputsForms.length)
|
||||
return null
|
||||
@ -304,23 +253,6 @@ const ChatWrapper = () => {
|
||||
/>
|
||||
: null
|
||||
|
||||
// 如果需要显示服务连接面板,则显示面板而非聊天界面
|
||||
if (showServiceConnection) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex h-full items-center justify-center overflow-auto bg-chatbot-bg',
|
||||
isMobile && 'px-4 py-8',
|
||||
)}>
|
||||
<ServiceConnectionPanel
|
||||
services={serviceConnections}
|
||||
onConnect={handleServiceConnect}
|
||||
onContinue={handleServiceContinue}
|
||||
className={cn(isMobile && 'max-w-full')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='h-full overflow-hidden bg-chatbot-bg'
|
||||
|
||||
@ -7,6 +7,7 @@ import type {
|
||||
ChatConfig,
|
||||
ChatItemInTree,
|
||||
Feedback,
|
||||
Memory,
|
||||
} from '../types'
|
||||
import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
|
||||
import type {
|
||||
@ -60,6 +61,14 @@ export type ChatWithHistoryContextValue = {
|
||||
name?: string
|
||||
avatar_url?: string
|
||||
}
|
||||
showChatMemory?: boolean
|
||||
setShowChatMemory: (state: boolean) => void
|
||||
memoryList: Memory[]
|
||||
clearAllMemory: () => void
|
||||
updateMemory: (memory: Memory, content: string) => void
|
||||
resetDefault: (memory: Memory) => void
|
||||
clearAllUpdateVersion: (memory: Memory) => void
|
||||
switchMemoryVersion: (memory: Memory, version: string) => void
|
||||
}
|
||||
|
||||
export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
|
||||
@ -95,5 +104,13 @@ export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>
|
||||
setCurrentConversationInputs: noop,
|
||||
allInputsHidden: false,
|
||||
initUserVariables: {},
|
||||
showChatMemory: false,
|
||||
setShowChatMemory: noop,
|
||||
memoryList: [],
|
||||
clearAllMemory: noop,
|
||||
updateMemory: noop,
|
||||
resetDefault: noop,
|
||||
clearAllUpdateVersion: noop,
|
||||
switchMemoryVersion: noop,
|
||||
})
|
||||
export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext)
|
||||
|
||||
@ -28,6 +28,8 @@ const HeaderInMobile = () => {
|
||||
handleRenameConversation,
|
||||
conversationRenaming,
|
||||
inputsForms,
|
||||
showChatMemory,
|
||||
setShowChatMemory,
|
||||
} = useChatWithHistoryContext()
|
||||
const { t } = useTranslation()
|
||||
const isPin = pinnedConversationList.some(item => item.id === currentConversationId)
|
||||
@ -60,6 +62,9 @@ const HeaderInMobile = () => {
|
||||
if (showRename)
|
||||
handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename })
|
||||
}, [showRename, handleRenameConversation, handleCancelRename])
|
||||
const handleChatMemoryToggle = useCallback(() => {
|
||||
setShowChatMemory(!showChatMemory)
|
||||
}, [setShowChatMemory, showChatMemory])
|
||||
const [showSidebar, setShowSidebar] = useState(false)
|
||||
const [showChatSettings, setShowChatSettings] = useState(false)
|
||||
|
||||
@ -98,6 +103,7 @@ const HeaderInMobile = () => {
|
||||
)}
|
||||
</div>
|
||||
<MobileOperationDropdown
|
||||
handleChatMemoryToggle={handleChatMemoryToggle}
|
||||
handleResetChat={handleNewConversation}
|
||||
handleViewChatSettings={() => setShowChatSettings(true)}
|
||||
hideViewChatSettings={inputsForms.length < 1}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiEditBoxLine,
|
||||
RiLayoutRight2Line,
|
||||
RiResetLeftLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Memory } from '@/app/components/base/icons/src/vender/line/others'
|
||||
import {
|
||||
useChatWithHistoryContext,
|
||||
} from '../context'
|
||||
@ -34,6 +35,8 @@ const Header = () => {
|
||||
sidebarCollapseState,
|
||||
handleSidebarCollapse,
|
||||
isResponding,
|
||||
showChatMemory,
|
||||
setShowChatMemory,
|
||||
} = useChatWithHistoryContext()
|
||||
const { t } = useTranslation()
|
||||
const isSidebarCollapsed = sidebarCollapseState
|
||||
@ -70,6 +73,10 @@ const Header = () => {
|
||||
handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename })
|
||||
}, [showRename, handleRenameConversation, handleCancelRename])
|
||||
|
||||
const handleChatMemoryToggle = useCallback(() => {
|
||||
setShowChatMemory(!showChatMemory)
|
||||
}, [setShowChatMemory, showChatMemory])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-14 shrink-0 items-center justify-between p-3'>
|
||||
@ -137,6 +144,15 @@ const Header = () => {
|
||||
{currentConversationId && inputsForms.length > 0 && (
|
||||
<ViewFormDropdown />
|
||||
)}
|
||||
{currentConversationId && (
|
||||
<Tooltip
|
||||
popupContent={t('share.chat.memory.actionButton')}
|
||||
>
|
||||
<ActionButton size='l' state={showChatMemory ? ActionButtonState.Active : ActionButtonState.Default} onClick={handleChatMemoryToggle}>
|
||||
<Memory className='h-[18px] w-[18px]' />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!!showConfirm && (
|
||||
|
||||
@ -9,12 +9,14 @@ import ActionButton, { ActionButtonState } from '@/app/components/base/action-bu
|
||||
type Props = {
|
||||
handleResetChat: () => void
|
||||
handleViewChatSettings: () => void
|
||||
handleChatMemoryToggle?: () => void
|
||||
hideViewChatSettings?: boolean
|
||||
}
|
||||
|
||||
const MobileOperationDropdown = ({
|
||||
handleResetChat,
|
||||
handleViewChatSettings,
|
||||
handleChatMemoryToggle,
|
||||
hideViewChatSettings = false,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
@ -44,6 +46,9 @@ const MobileOperationDropdown = ({
|
||||
<div className='system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' onClick={handleResetChat}>
|
||||
<span className='grow'>{t('share.chat.resetChat')}</span>
|
||||
</div>
|
||||
<div className='system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' onClick={handleChatMemoryToggle}>
|
||||
<span className='grow'>{t('share.chat.memory.actionButton')}</span>
|
||||
</div>
|
||||
{!hideViewChatSettings && (
|
||||
<div className='system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' onClick={handleViewChatSettings}>
|
||||
<span className='grow'>{t('share.chat.viewChatSettings')}</span>
|
||||
|
||||
@ -21,8 +21,11 @@ import { addFileInfos, sortAgentSorts } from '../../../tools/utils'
|
||||
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
|
||||
import {
|
||||
delConversation,
|
||||
deleteMemory,
|
||||
editMemory,
|
||||
fetchChatList,
|
||||
fetchConversations,
|
||||
fetchMemories,
|
||||
generationConversationName,
|
||||
pinConversation,
|
||||
renameConversation,
|
||||
@ -41,6 +44,9 @@ import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { noop } from 'lodash-es'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import type { Memory } from '@/app/components/base/chat/types'
|
||||
|
||||
import { mockMemoryList } from '@/app/components/base/chat/chat-with-history/memory/mock'
|
||||
|
||||
function getFormattedChatList(messages: any[]) {
|
||||
const newChatList: ChatItem[] = []
|
||||
@ -526,6 +532,61 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
notify({ type: 'success', message: t('common.api.success') })
|
||||
}, [isInstalledApp, appId, t, notify])
|
||||
|
||||
const [showChatMemory, setShowChatMemory] = useState(false)
|
||||
const [memoryList, setMemoryList] = useState<Memory[]>(mockMemoryList)
|
||||
|
||||
const getMemoryList = useCallback(async (currentConversationId: string) => {
|
||||
const memories = await fetchMemories(currentConversationId, '', '', isInstalledApp, appId)
|
||||
setMemoryList(memories)
|
||||
}, [isInstalledApp, appId])
|
||||
|
||||
const clearAllMemory = useCallback(async () => {
|
||||
await deleteMemory('', isInstalledApp, appId)
|
||||
notify({ type: 'success', message: t('common.api.success') })
|
||||
getMemoryList(currentConversationId)
|
||||
}, [currentConversationId, getMemoryList])
|
||||
|
||||
const resetDefault = useCallback(async (memory: Memory) => {
|
||||
try {
|
||||
await editMemory(memory.spec.id, memory.spec.template, isInstalledApp, appId)
|
||||
getMemoryList(currentConversationId)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to reset memory:', error)
|
||||
}
|
||||
}, [currentConversationId, getMemoryList, isInstalledApp, appId])
|
||||
|
||||
const clearAllUpdateVersion = useCallback(async (memory: Memory) => {
|
||||
await deleteMemory(memory.spec.id, isInstalledApp, appId)
|
||||
notify({ type: 'success', message: t('common.api.success') })
|
||||
getMemoryList(currentConversationId)
|
||||
}, [currentConversationId, getMemoryList])
|
||||
|
||||
const switchMemoryVersion = useCallback(async (memory: Memory, version: string) => {
|
||||
const memories = await fetchMemories(currentConversationId, memory.spec.id, version, isInstalledApp, appId)
|
||||
const newMemory = memories[0]
|
||||
const newList = produce(memoryList, (draft) => {
|
||||
const index = draft.findIndex(item => item.spec.id === memory.spec.id)
|
||||
if (index !== -1)
|
||||
draft[index] = newMemory
|
||||
})
|
||||
setMemoryList(newList)
|
||||
}, [memoryList, currentConversationId, isInstalledApp, appId])
|
||||
|
||||
const updateMemory = useCallback(async (memory: Memory, content: string) => {
|
||||
try {
|
||||
await editMemory(memory.spec.id, content, isInstalledApp, appId)
|
||||
getMemoryList(currentConversationId)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to reset memory:', error)
|
||||
}
|
||||
}, [getMemoryList, currentConversationId, isInstalledApp, appId])
|
||||
|
||||
useEffect(() => {
|
||||
getMemoryList(currentConversationId)
|
||||
}, [currentConversationId, getMemoryList])
|
||||
|
||||
return {
|
||||
isInstalledApp,
|
||||
appId,
|
||||
@ -572,5 +633,13 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
showChatMemory,
|
||||
setShowChatMemory,
|
||||
memoryList,
|
||||
clearAllMemory,
|
||||
updateMemory,
|
||||
resetDefault,
|
||||
clearAllUpdateVersion,
|
||||
switchMemoryVersion,
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import Sidebar from './sidebar'
|
||||
import Header from './header'
|
||||
import HeaderInMobile from './header-in-mobile'
|
||||
import ChatWrapper from './chat-wrapper'
|
||||
import MemoryPanel from './memory'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
@ -33,6 +34,14 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
|
||||
isMobile,
|
||||
themeBuilder,
|
||||
sidebarCollapseState,
|
||||
showChatMemory,
|
||||
setShowChatMemory,
|
||||
memoryList,
|
||||
clearAllMemory,
|
||||
updateMemory,
|
||||
resetDefault,
|
||||
clearAllUpdateVersion,
|
||||
switchMemoryVersion,
|
||||
} = useChatWithHistoryContext()
|
||||
const isSidebarCollapsed = sidebarCollapseState
|
||||
const customConfig = appData?.custom_config
|
||||
@ -68,7 +77,7 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
|
||||
{isMobile && (
|
||||
<HeaderInMobile />
|
||||
)}
|
||||
<div className={cn('relative grow p-2', isMobile && 'h-[calc(100%_-_56px)] p-0')}>
|
||||
<div className={cn('relative flex grow p-2', isMobile && 'h-[calc(100%_-_56px)] p-0')}>
|
||||
{isSidebarCollapsed && (
|
||||
<div
|
||||
className={cn(
|
||||
@ -81,7 +90,11 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
|
||||
<Sidebar isPanel panelVisible={showSidePanel} />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('flex h-full flex-col overflow-hidden border-[0,5px] border-components-panel-border-subtle bg-chatbot-bg', isMobile ? 'rounded-t-2xl' : 'rounded-2xl')}>
|
||||
<div className={cn(
|
||||
'flex h-full grow flex-col overflow-hidden border-[0,5px] border-components-panel-border-subtle bg-chatbot-bg',
|
||||
isMobile ? 'rounded-t-2xl' : 'rounded-2xl',
|
||||
showChatMemory && !isMobile && 'mr-1',
|
||||
)}>
|
||||
{!isMobile && <Header />}
|
||||
{appChatListDataLoading && (
|
||||
<Loading type='app' />
|
||||
@ -90,6 +103,38 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
|
||||
<ChatWrapper key={chatShouldReloadKey} />
|
||||
)}
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<MemoryPanel
|
||||
isMobile={isMobile}
|
||||
showChatMemory={showChatMemory}
|
||||
setShowChatMemory={setShowChatMemory}
|
||||
memoryList={memoryList}
|
||||
clearAllMemory={clearAllMemory}
|
||||
updateMemory={updateMemory}
|
||||
resetDefault={resetDefault}
|
||||
clearAllUpdateVersion={clearAllUpdateVersion}
|
||||
switchMemoryVersion={switchMemoryVersion}
|
||||
/>
|
||||
)}
|
||||
{isMobile && showChatMemory && (
|
||||
<div className='fixed inset-0 z-50 flex flex-row-reverse bg-background-overlay p-1 backdrop-blur-sm'
|
||||
onClick={() => setShowChatMemory(false)}
|
||||
>
|
||||
<div className='flex h-full w-[360px] rounded-xl shadow-lg' onClick={e => e.stopPropagation()}>
|
||||
<MemoryPanel
|
||||
isMobile={isMobile}
|
||||
showChatMemory={showChatMemory}
|
||||
setShowChatMemory={setShowChatMemory}
|
||||
memoryList={memoryList}
|
||||
clearAllMemory={clearAllMemory}
|
||||
updateMemory={updateMemory}
|
||||
resetDefault={resetDefault}
|
||||
clearAllUpdateVersion={clearAllUpdateVersion}
|
||||
switchMemoryVersion={switchMemoryVersion}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -145,6 +190,14 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
showChatMemory,
|
||||
setShowChatMemory,
|
||||
memoryList,
|
||||
clearAllMemory,
|
||||
updateMemory,
|
||||
resetDefault,
|
||||
clearAllUpdateVersion,
|
||||
switchMemoryVersion,
|
||||
} = useChatWithHistory(installedAppInfo)
|
||||
|
||||
return (
|
||||
@ -188,6 +241,14 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
showChatMemory,
|
||||
setShowChatMemory,
|
||||
memoryList,
|
||||
clearAllMemory,
|
||||
updateMemory,
|
||||
resetDefault,
|
||||
clearAllUpdateVersion,
|
||||
switchMemoryVersion,
|
||||
}}>
|
||||
<ChatWithHistory className={className} />
|
||||
</ChatWithHistoryContext.Provider>
|
||||
|
||||
@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { Memory } from '@/app/components/base/icons/src/vender/line/others'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import type { Memory as MemoryItem } from '@/app/components/base/chat/types'
|
||||
import { noop } from 'lodash-es'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
memory: MemoryItem
|
||||
show: boolean
|
||||
onConfirm: (info: MemoryItem, content: string) => Promise<void>
|
||||
onHide: () => void
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
const MemoryEditModal = ({
|
||||
memory,
|
||||
show = false,
|
||||
onConfirm,
|
||||
onHide,
|
||||
isMobile,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [content, setContent] = React.useState(memory.value)
|
||||
|
||||
const versionTag = useMemo(() => {
|
||||
const res = `${t('share.chat.memory.updateVersion.update')} ${memory.version}`
|
||||
if (memory.edited_by_user)
|
||||
return `${res} · ${t('share.chat.memory.updateVersion.edited')}`
|
||||
return res
|
||||
}, [memory.version, t])
|
||||
|
||||
const reset = () => {
|
||||
setContent(memory.value)
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!content.trim()) {
|
||||
Toast.notify({ type: 'error', message: 'content is required' })
|
||||
return
|
||||
}
|
||||
onConfirm(memory, content)
|
||||
onHide()
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className='fixed inset-0 z-50 flex flex-col bg-background-overlay pt-3 backdrop-blur-sm'
|
||||
onClick={onHide}
|
||||
>
|
||||
<div className='relative flex w-full grow flex-col rounded-t-xl bg-components-panel-bg shadow-xl' onClick={e => e.stopPropagation()}>
|
||||
<div className='absolute right-4 top-4 cursor-pointer p-2'>
|
||||
<ActionButton onClick={onHide}>
|
||||
<RiCloseLine className='h-5 w-5' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className='p-4 pb-3'>
|
||||
<div className='title-2xl-semi-bold mb-2 text-text-primary'>{t('share.chat.memory.editTitle')}</div>
|
||||
<div className='flex items-center gap-1 pb-1 pt-2'>
|
||||
<Memory className='h-4 w-4 shrink-0 text-util-colors-teal-teal-700' />
|
||||
<div className='system-sm-semibold truncate text-text-primary'>{memory.spec.name}</div>
|
||||
{memory.version > 1 && <Badge text={versionTag} className='!h-4' />}
|
||||
</div>
|
||||
</div>
|
||||
<div className='grow px-4'>
|
||||
<Textarea
|
||||
className='h-full'
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-row-reverse items-center p-4'>
|
||||
<Button className='ml-2' variant='primary' onClick={submit}>{t('share.chat.memory.operations.save')}</Button>
|
||||
<Button className='ml-3' onClick={onHide}>{t('share.chat.memory.operations.cancel')}</Button>
|
||||
<Divider type='vertical' className='!mx-0 !h-4' />
|
||||
<Button className='mr-3' onClick={reset}>{t('share.chat.memory.operations.reset')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={noop}
|
||||
className={cn('relative !max-w-[800px]', 'p-0')}
|
||||
>
|
||||
<div className='absolute right-5 top-5 cursor-pointer p-2'>
|
||||
<ActionButton onClick={onHide}>
|
||||
<RiCloseLine className='h-5 w-5' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className='p-6 pb-3'>
|
||||
<div className='title-2xl-semi-bold mb-2 text-text-primary'>{t('share.chat.memory.editTitle')}</div>
|
||||
<div className='flex items-center gap-1 pb-1 pt-2'>
|
||||
<Memory className='h-4 w-4 shrink-0 text-util-colors-teal-teal-700' />
|
||||
<div className='system-sm-semibold truncate text-text-primary'>{memory.spec.name}</div>
|
||||
{memory.version > 1 && <Badge text={versionTag} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-6'>
|
||||
<Textarea
|
||||
className='h-[562px]'
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-row-reverse items-center p-6 pt-5'>
|
||||
<Button className='ml-2' variant='primary' onClick={submit}>{t('share.chat.memory.operations.save')}</Button>
|
||||
<Button className='ml-3' onClick={onHide}>{t('share.chat.memory.operations.cancel')}</Button>
|
||||
<Divider type='vertical' className='!mx-0 !h-4' />
|
||||
<Button className='mr-3' onClick={reset}>{t('share.chat.memory.operations.reset')}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MemoryEditModal
|
||||
@ -0,0 +1,127 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowUpSLine,
|
||||
} from '@remixicon/react'
|
||||
import { Memory } from '@/app/components/base/icons/src/vender/line/others'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import Operation from './operation'
|
||||
import MemoryEditModal from './edit-modal'
|
||||
import type { Memory as MemoryItem } from '@/app/components/base/chat/types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
isMobile?: boolean
|
||||
memory: MemoryItem
|
||||
updateMemory: (memory: MemoryItem, content: string) => void
|
||||
resetDefault: (memory: MemoryItem) => void
|
||||
clearAllUpdateVersion: (memory: MemoryItem) => void
|
||||
switchMemoryVersion: (memory: MemoryItem, version: string) => void
|
||||
}
|
||||
|
||||
const MemoryCard: React.FC<Props> = ({
|
||||
isMobile,
|
||||
memory,
|
||||
updateMemory,
|
||||
resetDefault,
|
||||
clearAllUpdateVersion,
|
||||
switchMemoryVersion,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isHovering, setIsHovering] = React.useState(false)
|
||||
const [showEditModal, setShowEditModal] = React.useState(false)
|
||||
|
||||
const versionTag = useMemo(() => {
|
||||
const res = `${t('share.chat.memory.updateVersion.update')} ${memory.version}`
|
||||
if (memory.edited_by_user)
|
||||
return `${res} · ${t('share.chat.memory.updateVersion.edited')}`
|
||||
return res
|
||||
}, [memory.version, t])
|
||||
|
||||
const isLatest = useMemo(() => {
|
||||
if (memory.conversation_metadata)
|
||||
return memory.conversation_metadata.visible_count === memory.spec.preserved_turns
|
||||
|
||||
return true
|
||||
}, [memory])
|
||||
|
||||
const waitMergeCount = useMemo(() => {
|
||||
if (memory.conversation_metadata)
|
||||
return memory.conversation_metadata.visible_count - memory.spec.preserved_turns
|
||||
|
||||
return 0
|
||||
}, [memory])
|
||||
|
||||
const prevVersion = () => {
|
||||
if (memory.version > 1)
|
||||
switchMemoryVersion(memory, (memory.version - 1).toString())
|
||||
}
|
||||
|
||||
const nextVersion = () => {
|
||||
switchMemoryVersion(memory, (memory.version + 1).toString())
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn('group mb-1 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-md')}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
<div className='relative flex items-end justify-between pb-1 pl-4 pr-2 pt-2'>
|
||||
<div className='flex items-center gap-1 pb-1 pt-2'>
|
||||
<Memory className='h-4 w-4 shrink-0 text-util-colors-teal-teal-700' />
|
||||
<div className='system-sm-semibold truncate text-text-primary'>{memory.spec.name}</div>
|
||||
{memory.version > 1 && <Badge text={versionTag} className='!h-4' />}
|
||||
</div>
|
||||
{isHovering && (
|
||||
<div className='hover:bg-components-actionbar-bg-hover absolute bottom-0 right-2 flex items-center gap-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md'>
|
||||
<ActionButton onClick={prevVersion}><RiArrowUpSLine className='h-4 w-4' /></ActionButton>
|
||||
<ActionButton onClick={nextVersion}><RiArrowDownSLine className='h-4 w-4' /></ActionButton>
|
||||
<Operation
|
||||
memory={memory}
|
||||
onEdit={() => {
|
||||
setShowEditModal(true)
|
||||
setIsHovering(false)
|
||||
}}
|
||||
resetDefault={resetDefault}
|
||||
clearAllUpdateVersion={clearAllUpdateVersion}
|
||||
switchMemoryVersion={switchMemoryVersion}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='system-xs-regular line-clamp-[12] px-4 pb-2 pt-1 text-text-tertiary'>{memory.value}</div>
|
||||
{isLatest && (
|
||||
<div className='flex items-center gap-1 rounded-b-xl border-t-[0.5px] border-divider-subtle bg-background-default-subtle px-4 py-3 group-hover:bg-components-panel-on-panel-item-bg-hover'>
|
||||
<div className='system-xs-regular text-text-tertiary'>{t('share.chat.memory.latestVersion')}</div>
|
||||
<Indicator color='green' />
|
||||
</div>
|
||||
)}
|
||||
{!isLatest && (
|
||||
<div className='flex items-center gap-1 rounded-b-xl border-t-[0.5px] border-divider-subtle bg-background-default-subtle px-4 py-3 group-hover:bg-components-panel-on-panel-item-bg-hover'>
|
||||
<div className='system-xs-regular text-text-tertiary'>{t('share.chat.memory.notLatestVersion', { num: waitMergeCount })}</div>
|
||||
<Indicator color='orange' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showEditModal && (
|
||||
<MemoryEditModal
|
||||
isMobile={isMobile}
|
||||
show={showEditModal}
|
||||
memory={memory}
|
||||
onConfirm={async (info, content) => {
|
||||
await updateMemory(info, content)
|
||||
setShowEditModal(false)
|
||||
}}
|
||||
onHide={() => setShowEditModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MemoryCard
|
||||
@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCheckLine, RiMoreFill } from '@remixicon/react'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import type { Memory } from '@/app/components/base/chat/types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
memory: Memory
|
||||
onEdit: () => void
|
||||
resetDefault: (memory: Memory) => void
|
||||
clearAllUpdateVersion: (memory: Memory) => void
|
||||
switchMemoryVersion: (memory: Memory, version: string) => void
|
||||
}
|
||||
|
||||
const OperationDropdown: FC<Props> = ({
|
||||
memory,
|
||||
onEdit,
|
||||
resetDefault,
|
||||
clearAllUpdateVersion,
|
||||
switchMemoryVersion,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, doSetOpen] = useState(false)
|
||||
const openRef = useRef(open)
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
doSetOpen(v)
|
||||
openRef.current = v
|
||||
}, [doSetOpen])
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<div>
|
||||
<ActionButton className={cn(open && 'bg-state-base-hover')}>
|
||||
<RiMoreFill className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-50'>
|
||||
<div className='w-[220px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
|
||||
<div className='p-1'>
|
||||
<div className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' onClick={onEdit}>{t('share.chat.memory.operations.edit')}</div>
|
||||
<div className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' onClick={() => resetDefault(memory)}>{t('share.chat.memory.operations.reset')}</div>
|
||||
<div className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive' onClick={() => clearAllUpdateVersion(memory)}>{t('share.chat.memory.operations.clear')}</div>
|
||||
</div>
|
||||
<Divider className='!my-0 !h-px bg-divider-subtle' />
|
||||
<div className='px-1 py-2'>
|
||||
<div className='system-xs-medium-uppercase px-3 pb-0.5 pt-1 text-text-tertiary'>{t('share.chat.memory.updateVersion.title')}</div>
|
||||
<div className='system-md-regular flex cursor-pointer items-center gap-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>
|
||||
{t('share.chat.memory.operations.edit')}
|
||||
<RiCheckLine className='h-4 w-4 text-text-accent' />
|
||||
</div>
|
||||
<div className='system-md-regular flex cursor-pointer items-center gap-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>
|
||||
{t('share.chat.memory.operations.edit')}
|
||||
<RiCheckLine className='h-4 w-4 text-text-accent' />
|
||||
</div>
|
||||
<div className='system-md-regular flex cursor-pointer items-center gap-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>
|
||||
{t('share.chat.memory.operations.edit')}
|
||||
<RiCheckLine className='h-4 w-4 text-text-accent' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default React.memo(OperationDropdown)
|
||||
@ -0,0 +1,80 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiDeleteBinLine,
|
||||
} from '@remixicon/react'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Button from '@/app/components/base/button'
|
||||
import MemoryCard from './card'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
import type { Memory } from '@/app/components/base/chat/types'
|
||||
|
||||
type Props = {
|
||||
isMobile?: boolean
|
||||
showChatMemory?: boolean
|
||||
setShowChatMemory: (show: boolean) => void
|
||||
memoryList: Memory[]
|
||||
clearAllMemory: () => void
|
||||
updateMemory: (memory: Memory, content: string) => void
|
||||
resetDefault: (memory: Memory) => void
|
||||
clearAllUpdateVersion: (memory: Memory) => void
|
||||
switchMemoryVersion: (memory: Memory, version: string) => void
|
||||
}
|
||||
|
||||
const MemoryPanel: React.FC<Props> = ({
|
||||
isMobile,
|
||||
showChatMemory,
|
||||
setShowChatMemory,
|
||||
memoryList,
|
||||
clearAllMemory,
|
||||
updateMemory,
|
||||
resetDefault,
|
||||
clearAllUpdateVersion,
|
||||
switchMemoryVersion,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex h-full w-[360px] shrink-0 flex-col rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-chatbot-bg transition-all ease-in-out',
|
||||
showChatMemory ? 'w-[360px]' : 'w-0 opacity-0',
|
||||
)}>
|
||||
<div className='flex shrink-0 items-center border-b-[0.5px] border-components-panel-border-subtle pl-4 pr-3.5 pt-2'>
|
||||
<div className='system-md-semibold-uppercase grow py-3 text-text-primary'>{t('share.chat.memory.title')}</div>
|
||||
<ActionButton size='l' onClick={() => setShowChatMemory(false)}>
|
||||
<RiCloseLine className='h-[18px] w-[18px]' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className='h-0 grow overflow-y-auto px-3 pt-2'>
|
||||
{memoryList.map(memory => (
|
||||
<MemoryCard
|
||||
key={memory.spec.id}
|
||||
isMobile={isMobile}
|
||||
memory={memory}
|
||||
updateMemory={updateMemory}
|
||||
resetDefault={resetDefault}
|
||||
clearAllUpdateVersion={clearAllUpdateVersion}
|
||||
switchMemoryVersion={switchMemoryVersion}
|
||||
/>
|
||||
))}
|
||||
{memoryList.length > 0 && (
|
||||
<div className='flex items-center justify-center'>
|
||||
<Button variant='ghost' onClick={clearAllMemory}>
|
||||
<RiDeleteBinLine className='mr-1 h-3.5 w-3.5' />
|
||||
{t('share.chat.memory.clearAll')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{memoryList.length === 0 && (
|
||||
<div className='system-xs-regular flex items-center justify-center py-2 text-text-tertiary'>
|
||||
{t('share.chat.memory.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MemoryPanel
|
||||
@ -0,0 +1,96 @@
|
||||
import type { Memory as MemoryItem } from '@/app/components/base/chat/types'
|
||||
|
||||
export const mockMemoryList: MemoryItem[] = [
|
||||
{
|
||||
tenant_id: 'user-tenant-id',
|
||||
value: `Learning Goal: [What you\'re studying]
|
||||
Current Level: [Beginner/Intermediate/Advanced]
|
||||
Learning Style: [Visual, hands-on, theoretical, etc.]
|
||||
Progress: [Topics mastered, current focus]
|
||||
Preferred Pace: [Fast/moderate/slow explanations]
|
||||
Background: [Relevant experience or education]
|
||||
Time Constraints: [Available study time]`,
|
||||
app_id: 'user-app-id',
|
||||
conversation_id: '',
|
||||
version: 1,
|
||||
edited_by_user: false,
|
||||
conversation_metadata: {
|
||||
type: 'mutable_visible_window',
|
||||
visible_count: 5,
|
||||
},
|
||||
spec: {
|
||||
id: 'learning_companion',
|
||||
name: 'Learning Companion',
|
||||
description: 'A companion to help with learning goals',
|
||||
template: 'no zuo no die why you try', // default value
|
||||
instruction: 'enjoy yourself',
|
||||
scope: 'app', // app or node
|
||||
term: 'session', // session or persistent
|
||||
strategy: 'on_turns',
|
||||
update_turns: 3,
|
||||
preserved_turns: 5,
|
||||
schedule_mode: 'sync', // sync or async
|
||||
end_user_visible: true,
|
||||
end_user_editable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
tenant_id: 'user-tenant-id',
|
||||
value: `Research Topic: [Your research topic]
|
||||
Current Progress: [Literature review, experiments, etc.]
|
||||
Challenges: [What you\'re struggling with]
|
||||
Goals: [Short-term and long-term research goals]`,
|
||||
app_id: 'user-app-id',
|
||||
conversation_id: '',
|
||||
version: 1,
|
||||
edited_by_user: false,
|
||||
conversation_metadata: {
|
||||
type: 'mutable_visible_window',
|
||||
visible_count: 5,
|
||||
},
|
||||
spec: {
|
||||
id: 'research_partner',
|
||||
name: 'research_partner',
|
||||
description: 'A companion to help with research goals',
|
||||
template: 'no zuo no die why you try', // default value
|
||||
instruction: 'enjoy yourself',
|
||||
scope: 'app', // app or node
|
||||
term: 'session', // session or persistent
|
||||
strategy: 'on_turns',
|
||||
update_turns: 3,
|
||||
preserved_turns: 3,
|
||||
schedule_mode: 'sync', // sync or async
|
||||
end_user_visible: true,
|
||||
end_user_editable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
tenant_id: 'user-tenant-id',
|
||||
value: `Code Context: [Brief description of the codebase]
|
||||
Current Issues: [Bugs, technical debt, etc.]
|
||||
Goals: [Features to implement, improvements to make]`,
|
||||
app_id: 'user-app-id',
|
||||
conversation_id: '',
|
||||
version: 3,
|
||||
edited_by_user: true,
|
||||
conversation_metadata: {
|
||||
type: 'mutable_visible_window',
|
||||
visible_count: 5,
|
||||
},
|
||||
spec: {
|
||||
id: 'code_partner',
|
||||
name: 'code_partner',
|
||||
description: 'A companion to help with code-related tasks',
|
||||
template: 'no zuo no die why you try', // default value
|
||||
instruction: 'enjoy yourself',
|
||||
scope: 'app', // app or node
|
||||
term: 'session', // session or persistent
|
||||
strategy: 'on_turns',
|
||||
update_turns: 3,
|
||||
preserved_turns: 5,
|
||||
schedule_mode: 'sync', // sync or async
|
||||
end_user_visible: true,
|
||||
end_user_editable: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
@ -11,10 +11,7 @@ import {
|
||||
RiThumbDownLine,
|
||||
RiThumbUpLine,
|
||||
} from '@remixicon/react'
|
||||
import type {
|
||||
ChatItem,
|
||||
Feedback,
|
||||
} from '../../types'
|
||||
import type { ChatItem } from '../../types'
|
||||
import { useChatContext } from '../context'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
@ -25,7 +22,6 @@ import ActionButton, { ActionButtonState } from '@/app/components/base/action-bu
|
||||
import NewAudioButton from '@/app/components/base/new-audio-button'
|
||||
import Modal from '@/app/components/base/modal/modal'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type OperationProps = {
|
||||
@ -70,9 +66,8 @@ const Operation: FC<OperationProps> = ({
|
||||
adminFeedback,
|
||||
agent_thoughts,
|
||||
} = item
|
||||
const [userLocalFeedback, setUserLocalFeedback] = useState(feedback)
|
||||
const [localFeedback, setLocalFeedback] = useState(config?.supportAnnotation ? adminFeedback : feedback)
|
||||
const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback)
|
||||
const [feedbackTarget, setFeedbackTarget] = useState<'user' | 'admin'>('user')
|
||||
|
||||
// Separate feedback types for display
|
||||
const userFeedback = feedback
|
||||
@ -84,68 +79,24 @@ const Operation: FC<OperationProps> = ({
|
||||
return messageContent
|
||||
}, [agent_thoughts, messageContent])
|
||||
|
||||
const displayUserFeedback = userLocalFeedback ?? userFeedback
|
||||
|
||||
const hasUserFeedback = !!displayUserFeedback?.rating
|
||||
const hasAdminFeedback = !!adminLocalFeedback?.rating
|
||||
|
||||
const shouldShowUserFeedbackBar = !isOpeningStatement && config?.supportFeedback && !!onFeedback && !config?.supportAnnotation
|
||||
const shouldShowAdminFeedbackBar = !isOpeningStatement && config?.supportFeedback && !!onFeedback && !!config?.supportAnnotation
|
||||
|
||||
const userFeedbackLabel = t('appLog.table.header.userRate') || 'User feedback'
|
||||
const adminFeedbackLabel = t('appLog.table.header.adminRate') || 'Admin feedback'
|
||||
const feedbackTooltipClassName = 'max-w-[260px]'
|
||||
|
||||
const buildFeedbackTooltip = (feedbackData?: Feedback | null, label = userFeedbackLabel) => {
|
||||
if (!feedbackData?.rating)
|
||||
return label
|
||||
|
||||
const ratingLabel = feedbackData.rating === 'like'
|
||||
? (t('appLog.detail.operation.like') || 'like')
|
||||
: (t('appLog.detail.operation.dislike') || 'dislike')
|
||||
const feedbackText = feedbackData.content?.trim()
|
||||
|
||||
if (feedbackText)
|
||||
return `${label}: ${ratingLabel} - ${feedbackText}`
|
||||
|
||||
return `${label}: ${ratingLabel}`
|
||||
}
|
||||
|
||||
const handleFeedback = async (rating: 'like' | 'dislike' | null, content?: string, target: 'user' | 'admin' = 'user') => {
|
||||
const handleFeedback = async (rating: 'like' | 'dislike' | null, content?: string) => {
|
||||
if (!config?.supportFeedback || !onFeedback)
|
||||
return
|
||||
|
||||
await onFeedback?.(id, { rating, content })
|
||||
setLocalFeedback({ rating })
|
||||
|
||||
const nextFeedback = rating === null ? { rating: null } : { rating, content }
|
||||
|
||||
if (target === 'admin')
|
||||
setAdminLocalFeedback(nextFeedback)
|
||||
else
|
||||
setUserLocalFeedback(nextFeedback)
|
||||
// Update admin feedback state separately if annotation is supported
|
||||
if (config?.supportAnnotation)
|
||||
setAdminLocalFeedback(rating ? { rating } : undefined)
|
||||
}
|
||||
|
||||
const handleLikeClick = (target: 'user' | 'admin') => {
|
||||
const currentRating = target === 'admin' ? adminLocalFeedback?.rating : displayUserFeedback?.rating
|
||||
if (currentRating === 'like') {
|
||||
handleFeedback(null, undefined, target)
|
||||
return
|
||||
}
|
||||
handleFeedback('like', undefined, target)
|
||||
}
|
||||
|
||||
const handleDislikeClick = (target: 'user' | 'admin') => {
|
||||
const currentRating = target === 'admin' ? adminLocalFeedback?.rating : displayUserFeedback?.rating
|
||||
if (currentRating === 'dislike') {
|
||||
handleFeedback(null, undefined, target)
|
||||
return
|
||||
}
|
||||
setFeedbackTarget(target)
|
||||
const handleThumbsDown = () => {
|
||||
setIsShowFeedbackModal(true)
|
||||
}
|
||||
|
||||
const handleFeedbackSubmit = async () => {
|
||||
await handleFeedback('dislike', feedbackContent, feedbackTarget)
|
||||
await handleFeedback('dislike', feedbackContent)
|
||||
setFeedbackContent('')
|
||||
setIsShowFeedbackModal(false)
|
||||
}
|
||||
@ -165,13 +116,12 @@ const Operation: FC<OperationProps> = ({
|
||||
width += 26
|
||||
if (!isOpeningStatement && config?.supportAnnotation && config?.annotation_reply?.enabled)
|
||||
width += 26
|
||||
if (shouldShowUserFeedbackBar)
|
||||
width += hasUserFeedback ? 28 + 8 : 60 + 8
|
||||
if (shouldShowAdminFeedbackBar)
|
||||
width += (hasAdminFeedback ? 28 : 60) + 8 + (hasUserFeedback ? 28 : 0)
|
||||
|
||||
if (config?.supportFeedback && !localFeedback?.rating && onFeedback && !isOpeningStatement)
|
||||
width += 60 + 8
|
||||
if (config?.supportFeedback && localFeedback?.rating && onFeedback && !isOpeningStatement)
|
||||
width += 28 + 8
|
||||
return width
|
||||
}, [config?.annotation_reply?.enabled, config?.supportAnnotation, config?.text_to_speech?.enabled, hasAdminFeedback, hasUserFeedback, isOpeningStatement, shouldShowAdminFeedbackBar, shouldShowUserFeedbackBar, showPromptLog])
|
||||
}, [isOpeningStatement, showPromptLog, config?.text_to_speech?.enabled, config?.supportAnnotation, config?.annotation_reply?.enabled, config?.supportFeedback, localFeedback?.rating, onFeedback])
|
||||
|
||||
const positionRight = useMemo(() => operationWidth < maxSize, [operationWidth, maxSize])
|
||||
|
||||
@ -186,110 +136,6 @@ const Operation: FC<OperationProps> = ({
|
||||
)}
|
||||
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
|
||||
>
|
||||
{shouldShowUserFeedbackBar && (
|
||||
<div className={cn(
|
||||
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
|
||||
hasUserFeedback ? 'flex' : 'hidden group-hover:flex',
|
||||
)}>
|
||||
{hasUserFeedback ? (
|
||||
<Tooltip
|
||||
popupContent={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
|
||||
popupClassName={feedbackTooltipClassName}
|
||||
>
|
||||
<ActionButton
|
||||
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Destructive}
|
||||
onClick={() => handleFeedback(null, undefined, 'user')}
|
||||
>
|
||||
{displayUserFeedback?.rating === 'like'
|
||||
? <RiThumbUpLine className='h-4 w-4' />
|
||||
: <RiThumbDownLine className='h-4 w-4' />}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
<ActionButton
|
||||
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
|
||||
onClick={() => handleLikeClick('user')}
|
||||
>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
state={displayUserFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
|
||||
onClick={() => handleDislikeClick('user')}
|
||||
>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{shouldShowAdminFeedbackBar && (
|
||||
<div className={cn(
|
||||
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
|
||||
(hasAdminFeedback || hasUserFeedback) ? 'flex' : 'hidden group-hover:flex',
|
||||
)}>
|
||||
{/* User Feedback Display */}
|
||||
{displayUserFeedback?.rating && (
|
||||
<Tooltip
|
||||
popupContent={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
|
||||
popupClassName={feedbackTooltipClassName}
|
||||
>
|
||||
{displayUserFeedback.rating === 'like' ? (
|
||||
<ActionButton state={ActionButtonState.Active}>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton state={ActionButtonState.Destructive}>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Admin Feedback Controls */}
|
||||
{displayUserFeedback?.rating && <div className='mx-1 h-3 w-[0.5px] bg-components-actionbar-border' />}
|
||||
{hasAdminFeedback ? (
|
||||
<Tooltip
|
||||
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
|
||||
popupClassName={feedbackTooltipClassName}
|
||||
>
|
||||
<ActionButton
|
||||
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Destructive}
|
||||
onClick={() => handleFeedback(null, undefined, 'admin')}
|
||||
>
|
||||
{adminLocalFeedback?.rating === 'like'
|
||||
? <RiThumbUpLine className='h-4 w-4' />
|
||||
: <RiThumbDownLine className='h-4 w-4' />}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip
|
||||
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
|
||||
popupClassName={feedbackTooltipClassName}
|
||||
>
|
||||
<ActionButton
|
||||
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
|
||||
onClick={() => handleLikeClick('admin')}
|
||||
>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
|
||||
popupClassName={feedbackTooltipClassName}
|
||||
>
|
||||
<ActionButton
|
||||
state={adminLocalFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
|
||||
onClick={() => handleDislikeClick('admin')}
|
||||
>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showPromptLog && !isOpeningStatement && (
|
||||
<div className='hidden group-hover:block'>
|
||||
<Log logItem={item} />
|
||||
@ -328,6 +174,69 @@ const Operation: FC<OperationProps> = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isOpeningStatement && config?.supportFeedback && !localFeedback?.rating && onFeedback && (
|
||||
<div className='ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex'>
|
||||
{!localFeedback?.rating && (
|
||||
<>
|
||||
<ActionButton onClick={() => handleFeedback('like')}>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
<ActionButton onClick={handleThumbsDown}>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isOpeningStatement && config?.supportFeedback && onFeedback && (
|
||||
<div className='ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm'>
|
||||
{/* User Feedback Display */}
|
||||
{userFeedback?.rating && (
|
||||
<div className='flex items-center'>
|
||||
<span className='mr-1 text-xs text-text-tertiary'>User</span>
|
||||
{userFeedback.rating === 'like' ? (
|
||||
<ActionButton state={ActionButtonState.Active} title={userFeedback.content ? `User liked this response: ${userFeedback.content}` : 'User liked this response'}>
|
||||
<RiThumbUpLine className='h-3 w-3' />
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton state={ActionButtonState.Destructive} title={userFeedback.content ? `User disliked this response: ${userFeedback.content}` : 'User disliked this response'}>
|
||||
<RiThumbDownLine className='h-3 w-3' />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Feedback Controls */}
|
||||
{config?.supportAnnotation && (
|
||||
<div className='flex items-center'>
|
||||
{userFeedback?.rating && <div className='mx-1 h-3 w-[0.5px] bg-components-actionbar-border' />}
|
||||
{!adminLocalFeedback?.rating ? (
|
||||
<>
|
||||
<ActionButton onClick={() => handleFeedback('like')}>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
<ActionButton onClick={handleThumbsDown}>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{adminLocalFeedback.rating === 'like' ? (
|
||||
<ActionButton state={ActionButtonState.Active} onClick={() => handleFeedback(null)}>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton state={ActionButtonState.Destructive} onClick={() => handleFeedback(null)}>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<EditReplyModal
|
||||
isShow={isShowReplyModal}
|
||||
|
||||
@ -6,6 +6,7 @@ import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
Feedback,
|
||||
Memory,
|
||||
} from '../types'
|
||||
import type { ThemeBuilder } from './theme/theme-context'
|
||||
import type {
|
||||
@ -53,6 +54,14 @@ export type EmbeddedChatbotContextValue = {
|
||||
name?: string
|
||||
avatar_url?: string
|
||||
}
|
||||
showChatMemory?: boolean
|
||||
setShowChatMemory: (state: boolean) => void
|
||||
memoryList: Memory[]
|
||||
clearAllMemory: () => void
|
||||
updateMemory: (memory: Memory, content: string) => void
|
||||
resetDefault: (memory: Memory) => void
|
||||
clearAllUpdateVersion: (memory: Memory) => void
|
||||
switchMemoryVersion: (memory: Memory, version: string) => void
|
||||
}
|
||||
|
||||
export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({
|
||||
@ -86,5 +95,13 @@ export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>
|
||||
setCurrentConversationInputs: noop,
|
||||
allInputsHidden: false,
|
||||
initUserVariables: {},
|
||||
showChatMemory: false,
|
||||
setShowChatMemory: noop,
|
||||
memoryList: [],
|
||||
clearAllMemory: noop,
|
||||
updateMemory: noop,
|
||||
resetDefault: noop,
|
||||
clearAllUpdateVersion: noop,
|
||||
switchMemoryVersion: noop,
|
||||
})
|
||||
export const useEmbeddedChatbotContext = () => useContext(EmbeddedChatbotContext)
|
||||
|
||||
@ -7,8 +7,9 @@ import { CssTransform } from '../theme/utils'
|
||||
import {
|
||||
useEmbeddedChatbotContext,
|
||||
} from '../context'
|
||||
import { Memory } from '@/app/components/base/icons/src/vender/line/others'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
@ -36,6 +37,8 @@ const Header: FC<IHeaderProps> = ({
|
||||
appData,
|
||||
currentConversationId,
|
||||
inputsForms,
|
||||
showChatMemory,
|
||||
setShowChatMemory,
|
||||
allInputsHidden,
|
||||
} = useEmbeddedChatbotContext()
|
||||
|
||||
@ -77,6 +80,10 @@ const Header: FC<IHeaderProps> = ({
|
||||
}, parentOrigin)
|
||||
}, [isIframe, parentOrigin, showToggleExpandButton, expanded])
|
||||
|
||||
const handleChatMemoryToggle = useCallback(() => {
|
||||
setShowChatMemory(!showChatMemory)
|
||||
}, [setShowChatMemory, showChatMemory])
|
||||
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<div className='flex h-14 shrink-0 items-center justify-end p-3'>
|
||||
@ -128,6 +135,15 @@ const Header: FC<IHeaderProps> = ({
|
||||
{currentConversationId && inputsForms.length > 0 && !allInputsHidden && (
|
||||
<ViewFormDropdown />
|
||||
)}
|
||||
{currentConversationId && (
|
||||
<Tooltip
|
||||
popupContent={t('share.chat.memory.actionButton')}
|
||||
>
|
||||
<ActionButton size='l' state={showChatMemory ? ActionButtonState.Active : ActionButtonState.Default} onClick={handleChatMemoryToggle}>
|
||||
<Memory className='h-[18px] w-[18px]' />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -175,6 +191,15 @@ const Header: FC<IHeaderProps> = ({
|
||||
{currentConversationId && inputsForms.length > 0 && !allInputsHidden && (
|
||||
<ViewFormDropdown iconColor={theme?.colorPathOnHeader} />
|
||||
)}
|
||||
{currentConversationId && (
|
||||
<Tooltip
|
||||
popupContent={t('share.chat.memory.actionButton')}
|
||||
>
|
||||
<ActionButton size='l' onClick={handleChatMemoryToggle}>
|
||||
<Memory className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -18,8 +18,11 @@ import { CONVERSATION_ID_INFO } from '../constants'
|
||||
import { buildChatItemTree, getProcessedInputsFromUrlParams, getProcessedSystemVariablesFromUrlParams, getProcessedUserVariablesFromUrlParams } from '../utils'
|
||||
import { getProcessedFilesFromResponse } from '../../file-uploader/utils'
|
||||
import {
|
||||
deleteMemory,
|
||||
editMemory,
|
||||
fetchChatList,
|
||||
fetchConversations,
|
||||
fetchMemories,
|
||||
generationConversationName,
|
||||
updateFeedback,
|
||||
} from '@/service/share'
|
||||
@ -33,6 +36,7 @@ import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
|
||||
import { noop } from 'lodash-es'
|
||||
import type { Memory } from '@/app/components/base/chat/types'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
|
||||
function getFormattedChatList(messages: any[]) {
|
||||
@ -387,6 +391,61 @@ export const useEmbeddedChatbot = () => {
|
||||
notify({ type: 'success', message: t('common.api.success') })
|
||||
}, [isInstalledApp, appId, t, notify])
|
||||
|
||||
const [showChatMemory, setShowChatMemory] = useState(false)
|
||||
const [memoryList, setMemoryList] = useState<Memory[]>([])
|
||||
|
||||
const getMemoryList = useCallback(async (currentConversationId: string) => {
|
||||
const memories = await fetchMemories(currentConversationId, '', '', isInstalledApp, appId)
|
||||
setMemoryList(memories)
|
||||
}, [isInstalledApp, appId])
|
||||
|
||||
const clearAllMemory = useCallback(async () => {
|
||||
await deleteMemory('', isInstalledApp, appId)
|
||||
notify({ type: 'success', message: t('common.api.success') })
|
||||
getMemoryList(currentConversationId)
|
||||
}, [currentConversationId, getMemoryList])
|
||||
|
||||
const resetDefault = useCallback(async (memory: Memory) => {
|
||||
try {
|
||||
await editMemory(memory.spec.id, memory.spec.template, isInstalledApp, appId)
|
||||
getMemoryList(currentConversationId)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to reset memory:', error)
|
||||
}
|
||||
}, [currentConversationId, getMemoryList, isInstalledApp, appId])
|
||||
|
||||
const clearAllUpdateVersion = useCallback(async (memory: Memory) => {
|
||||
await deleteMemory(memory.spec.id, isInstalledApp, appId)
|
||||
notify({ type: 'success', message: t('common.api.success') })
|
||||
getMemoryList(currentConversationId)
|
||||
}, [currentConversationId, getMemoryList])
|
||||
|
||||
const switchMemoryVersion = useCallback(async (memory: Memory, version: string) => {
|
||||
const memories = await fetchMemories(currentConversationId, memory.spec.id, version, isInstalledApp, appId)
|
||||
const newMemory = memories[0]
|
||||
const newList = produce(memoryList, (draft) => {
|
||||
const index = draft.findIndex(item => item.spec.id === memory.spec.id)
|
||||
if (index !== -1)
|
||||
draft[index] = newMemory
|
||||
})
|
||||
setMemoryList(newList)
|
||||
}, [memoryList, currentConversationId, isInstalledApp, appId])
|
||||
|
||||
const updateMemory = useCallback(async (memory: Memory, content: string) => {
|
||||
try {
|
||||
await editMemory(memory.spec.id, content, isInstalledApp, appId)
|
||||
getMemoryList(currentConversationId)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to reset memory:', error)
|
||||
}
|
||||
}, [getMemoryList, currentConversationId, isInstalledApp, appId])
|
||||
|
||||
useEffect(() => {
|
||||
getMemoryList(currentConversationId)
|
||||
}, [currentConversationId, getMemoryList])
|
||||
|
||||
return {
|
||||
isInstalledApp,
|
||||
allowResetChat,
|
||||
@ -426,5 +485,13 @@ export const useEmbeddedChatbot = () => {
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
showChatMemory,
|
||||
setShowChatMemory,
|
||||
memoryList,
|
||||
clearAllMemory,
|
||||
updateMemory,
|
||||
resetDefault,
|
||||
clearAllUpdateVersion,
|
||||
switchMemoryVersion,
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import Loading from '@/app/components/base/loading'
|
||||
import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header'
|
||||
import Header from '@/app/components/base/chat/embedded-chatbot/header'
|
||||
import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper'
|
||||
import MemoryPanel from '@/app/components/base/chat/chat-with-history/memory'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import cn from '@/utils/classnames'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
@ -30,6 +31,14 @@ const Chatbot = () => {
|
||||
chatShouldReloadKey,
|
||||
handleNewConversation,
|
||||
themeBuilder,
|
||||
showChatMemory,
|
||||
setShowChatMemory,
|
||||
memoryList,
|
||||
clearAllMemory,
|
||||
updateMemory,
|
||||
resetDefault,
|
||||
clearAllUpdateVersion,
|
||||
switchMemoryVersion,
|
||||
} = useEmbeddedChatbotContext()
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
@ -90,6 +99,25 @@ const Chatbot = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showChatMemory && (
|
||||
<div className='fixed inset-0 z-50 flex flex-row-reverse bg-background-overlay p-1 backdrop-blur-sm'
|
||||
onClick={() => setShowChatMemory(false)}
|
||||
>
|
||||
<div className='flex h-full w-[360px] rounded-xl shadow-lg' onClick={e => e.stopPropagation()}>
|
||||
<MemoryPanel
|
||||
isMobile={isMobile}
|
||||
showChatMemory={showChatMemory}
|
||||
setShowChatMemory={setShowChatMemory}
|
||||
memoryList={memoryList}
|
||||
clearAllMemory={clearAllMemory}
|
||||
updateMemory={updateMemory}
|
||||
resetDefault={resetDefault}
|
||||
clearAllUpdateVersion={clearAllUpdateVersion}
|
||||
switchMemoryVersion={switchMemoryVersion}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -131,6 +159,14 @@ const EmbeddedChatbotWrapper = () => {
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
showChatMemory,
|
||||
setShowChatMemory,
|
||||
memoryList,
|
||||
clearAllMemory,
|
||||
updateMemory,
|
||||
resetDefault,
|
||||
clearAllUpdateVersion,
|
||||
switchMemoryVersion,
|
||||
} = useEmbeddedChatbot()
|
||||
|
||||
return <EmbeddedChatbotContext.Provider value={{
|
||||
@ -167,6 +203,14 @@ const EmbeddedChatbotWrapper = () => {
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
showChatMemory,
|
||||
setShowChatMemory,
|
||||
memoryList,
|
||||
clearAllMemory,
|
||||
updateMemory,
|
||||
resetDefault,
|
||||
clearAllUpdateVersion,
|
||||
switchMemoryVersion,
|
||||
}}>
|
||||
<Chatbot />
|
||||
</EmbeddedChatbotContext.Provider>
|
||||
|
||||
@ -95,3 +95,36 @@ export type Feedback = {
|
||||
rating: 'like' | 'dislike' | null
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
export type MemorySpec = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
template: string // default value
|
||||
instruction: string
|
||||
scope: string // app or node
|
||||
term: string // session or persistent
|
||||
strategy: string
|
||||
update_turns: number
|
||||
preserved_turns: number
|
||||
schedule_mode: string // sync or async
|
||||
end_user_visible: boolean
|
||||
end_user_editable: boolean
|
||||
}
|
||||
|
||||
export type ConversationMetaData = {
|
||||
type: string // mutable_visible_window
|
||||
visible_count: number // visible_count - preserved_turns = N messages waiting merged
|
||||
}
|
||||
|
||||
export type Memory = {
|
||||
tenant_id: string
|
||||
value: string
|
||||
app_id: string
|
||||
conversation_id?: string
|
||||
node_id?: string
|
||||
version: number
|
||||
edited_by_user: boolean
|
||||
conversation_metadata?: ConversationMetaData
|
||||
spec: MemorySpec
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
import useSWR from 'swr'
|
||||
import { produce } from 'immer'
|
||||
import React, { Fragment } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
@ -8,6 +9,7 @@ import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } fro
|
||||
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import { fetchAppVoices } from '@/service/apps'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import AudioBtn from '@/app/components/base/audio-btn'
|
||||
@ -15,7 +17,6 @@ import { languages } from '@/i18n-config/language'
|
||||
import { TtsAutoPlay } from '@/types/app'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { useAppVoices } from '@/service/use-apps'
|
||||
|
||||
type VoiceParamConfigProps = {
|
||||
onClose: () => void
|
||||
@ -38,7 +39,7 @@ const VoiceParamConfig = ({
|
||||
const localLanguagePlaceholder = languageItem?.name || t('common.placeholder.select')
|
||||
|
||||
const language = languageItem?.value
|
||||
const { data: voiceItems } = useAppVoices(appId, language)
|
||||
const voiceItems = useSWR({ appId, language }, fetchAppVoices).data
|
||||
let voiceItem = voiceItems?.find(item => item.value === text2speech?.voice)
|
||||
if (voiceItems && !voiceItem)
|
||||
voiceItem = voiceItems[0]
|
||||
|
||||
@ -9,7 +9,12 @@ import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { useTriggerPluginDynamicOptions } from '@/service/use-triggers'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiExternalLinkLine } from '@remixicon/react'
|
||||
import {
|
||||
RiArrowDownSFill,
|
||||
RiDraftLine,
|
||||
RiExternalLinkLine,
|
||||
RiInputField,
|
||||
} from '@remixicon/react'
|
||||
import type { AnyFieldApi } from '@tanstack/react-form'
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
import {
|
||||
@ -19,6 +24,19 @@ import {
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
import ObjectValueList from '@/app/components/workflow/panel/chat-variable-panel/components/object-value-list'
|
||||
import ArrayValueList from '@/app/components/workflow/panel/chat-variable-panel/components/array-value-list'
|
||||
import ArrayBooleanValueList from '@/app/components/workflow/panel/chat-variable-panel/components/array-bool-list'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import Button from '@/app/components/base/button'
|
||||
import PromptGeneratorBtn from '@/app/components/workflow/nodes/llm/components/prompt-generator-btn'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Switch from '../../../switch'
|
||||
import NodeSelector from '@/app/components/workflow/panel/chat-variable-panel/components/node-selector'
|
||||
|
||||
const getExtraProps = (type: FormTypeEnum) => {
|
||||
switch (type) {
|
||||
@ -100,7 +118,6 @@ const BaseField = ({
|
||||
options,
|
||||
labelClassName: formLabelClassName,
|
||||
disabled: formSchemaDisabled,
|
||||
type: formItemType,
|
||||
dynamicSelectParams,
|
||||
multiple = false,
|
||||
tooltip,
|
||||
@ -108,7 +125,14 @@ const BaseField = ({
|
||||
description,
|
||||
url,
|
||||
help,
|
||||
type: typeOrFn,
|
||||
fieldClassName: formFieldClassName,
|
||||
inputContainerClassName: formInputContainerClassName,
|
||||
inputClassName: formInputClassName,
|
||||
selfFormProps,
|
||||
onChange: formOnChange,
|
||||
} = formSchema
|
||||
const formItemType = typeof typeOrFn === 'function' ? typeOrFn(field.form) : typeOrFn
|
||||
const disabled = propsDisabled || formSchemaDisabled
|
||||
|
||||
const [translatedLabel, translatedPlaceholder, translatedTooltip, translatedDescription, translatedHelp] = useMemo(() => {
|
||||
@ -148,7 +172,8 @@ const BaseField = ({
|
||||
return true
|
||||
|
||||
return option.show_on.every((condition) => {
|
||||
return watchedValues[condition.variable] === condition.value
|
||||
const conditionValue = watchedValues[condition.variable]
|
||||
return Array.isArray(condition.value) ? condition.value.includes(conditionValue) : conditionValue === condition.value
|
||||
})
|
||||
}).map((option) => {
|
||||
return {
|
||||
@ -180,21 +205,67 @@ const BaseField = ({
|
||||
}))
|
||||
}, [dynamicOptionsData, renderI18nObject])
|
||||
|
||||
const booleanRadioValue = useMemo(() => {
|
||||
if (value === null || value === undefined)
|
||||
return undefined
|
||||
return value ? 1 : 0
|
||||
}, [value])
|
||||
|
||||
const handleChange = useCallback((value: any) => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
field.handleChange(value)
|
||||
formOnChange?.(field.form, value)
|
||||
onChange?.(field.name, value)
|
||||
}, [field, onChange])
|
||||
}, [field, formOnChange, onChange, disabled])
|
||||
|
||||
const selfProps = typeof selfFormProps === 'function' ? selfFormProps(field.form) : selfFormProps
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(fieldClassName)}>
|
||||
<div className={cn(labelClassName, formLabelClassName)}>
|
||||
{
|
||||
selfProps?.withTopDivider && (
|
||||
<div className='h-px w-full bg-divider-subtle' />
|
||||
)
|
||||
}
|
||||
<div className={cn(fieldClassName, formFieldClassName)}>
|
||||
<div
|
||||
className={cn(formItemType === FormTypeEnum.collapse && 'cursor-pointer', labelClassName, formLabelClassName)}
|
||||
onClick={() => {
|
||||
if (formItemType === FormTypeEnum.collapse)
|
||||
handleChange(!value)
|
||||
}}
|
||||
>
|
||||
{translatedLabel}
|
||||
{
|
||||
required && !isValidElement(label) && (
|
||||
<span className='ml-1 text-text-destructive-secondary'>*</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.collapse && (
|
||||
<RiArrowDownSFill
|
||||
className={cn(
|
||||
'h-4 w-4 text-text-quaternary',
|
||||
!value && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.editMode && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='small'
|
||||
className='text-text-tertiary'
|
||||
onClick={() => handleChange(!value)}
|
||||
>
|
||||
{value ? <RiInputField className='mr-1 h-3.5 w-3.5' /> : <RiDraftLine className='mr-1 h-3.5 w-3.5' />}
|
||||
{selfProps?.editModeLabel}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
popupContent={<div className='w-[200px]'>{translatedTooltip}</div>}
|
||||
@ -202,9 +273,9 @@ const BaseField = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn(inputContainerClassName)}>
|
||||
<div className={cn(inputContainerClassName, formInputContainerClassName)}>
|
||||
{
|
||||
[FormTypeEnum.textInput, FormTypeEnum.secretInput, FormTypeEnum.textNumber].includes(formItemType) && (
|
||||
!selfProps?.withSlider && [FormTypeEnum.textInput, FormTypeEnum.secretInput, FormTypeEnum.textNumber].includes(formItemType) && (
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
@ -221,6 +292,34 @@ const BaseField = ({
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.textNumber && selfProps?.withSlider && (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Slider
|
||||
min={selfProps?.sliderMin}
|
||||
max={selfProps?.sliderMax}
|
||||
step={selfProps?.sliderStep}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
className={cn(selfProps.sliderClassName)}
|
||||
trackClassName={cn(selfProps.sliderTrackClassName)}
|
||||
thumbClassName={cn(selfProps.sliderThumbClassName)}
|
||||
/>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type='number'
|
||||
className={cn('', inputClassName, formInputClassName)}
|
||||
wrapperClassName={cn(selfProps.inputWrapperClassName)}
|
||||
value={value || ''}
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
disabled={disabled}
|
||||
placeholder={translatedPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.select && !multiple && (
|
||||
<PureSelect
|
||||
@ -276,15 +375,16 @@ const BaseField = ({
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'system-sm-regular hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 flex-[1] grow cursor-pointer items-center justify-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary',
|
||||
'system-sm-regular hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 flex-[1] grow cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary',
|
||||
value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
inputClassName,
|
||||
formInputClassName,
|
||||
)}
|
||||
onClick={() => !disabled && handleChange(option.value)}
|
||||
onClick={() => handleChange(option.value)}
|
||||
>
|
||||
{
|
||||
formSchema.showRadioUI && (
|
||||
selfProps?.showRadioUI && (
|
||||
<RadioE
|
||||
className='mr-2'
|
||||
isChecked={value === option.value}
|
||||
@ -298,18 +398,151 @@ const BaseField = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.textareaInput && (
|
||||
<Textarea
|
||||
className={cn(
|
||||
'min-h-[80px]',
|
||||
inputClassName,
|
||||
formInputClassName,
|
||||
)}
|
||||
value={value}
|
||||
placeholder={translatedPlaceholder}
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.promptInput && (
|
||||
<div className={cn(
|
||||
'relative rounded-lg bg-components-input-bg-normal p-2',
|
||||
formInputContainerClassName,
|
||||
)}>
|
||||
{
|
||||
selfProps?.enablePromptGenerator && (
|
||||
<PromptGeneratorBtn
|
||||
nodeId={selfProps?.nodeId}
|
||||
editorId={selfProps?.editorId}
|
||||
className='absolute right-0 top-[-26px]'
|
||||
onGenerated={handleChange}
|
||||
modelConfig={selfProps?.modelConfig}
|
||||
currentPrompt={value}
|
||||
isBasicMode={selfProps?.isBasicMode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<PromptEditor
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={field.handleBlur}
|
||||
editable={!disabled}
|
||||
placeholder={translatedPlaceholder || selfProps?.placeholder}
|
||||
className={cn(
|
||||
'min-h-[80px]',
|
||||
inputClassName,
|
||||
formInputClassName,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.objectList && (
|
||||
<ObjectValueList
|
||||
list={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.arrayList && (
|
||||
<ArrayValueList
|
||||
isString={selfProps?.isString}
|
||||
list={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.booleanList && (
|
||||
<ArrayBooleanValueList
|
||||
list={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.jsonInput && (
|
||||
<div className='w-full rounded-[10px] bg-components-input-bg-normal py-2 pl-3 pr-1' style={{ height: selfProps?.editorMinHeight }}>
|
||||
<CodeEditor
|
||||
isExpand
|
||||
noWrapper
|
||||
language={CodeLanguage.json}
|
||||
value={value}
|
||||
placeholder={<div className='whitespace-pre'>{selfProps?.placeholder as string}</div>}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.modelSelector && (
|
||||
<ModelParameterModal
|
||||
popupClassName='!w-[387px]'
|
||||
modelId={value?.name}
|
||||
provider={value?.provider}
|
||||
setModel={({ modelId, mode, provider }) => {
|
||||
handleChange({
|
||||
mode,
|
||||
provider,
|
||||
name: modelId,
|
||||
completion_params: value?.completion_params,
|
||||
})
|
||||
}}
|
||||
completionParams={value?.completion_params}
|
||||
onCompletionParamsChange={(params) => {
|
||||
handleChange({
|
||||
...value,
|
||||
completion_params: params,
|
||||
})
|
||||
}}
|
||||
readonly={disabled}
|
||||
isAdvancedMode
|
||||
isInWorkflow
|
||||
hideDebugWithMultipleModel
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.nodeSelector && (
|
||||
<NodeSelector
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.boolean && (
|
||||
<Radio.Group
|
||||
className='flex w-fit items-center'
|
||||
value={value}
|
||||
onChange={v => field.handleChange(v)}
|
||||
className={cn('flex w-full items-center space-x-1', inputClassName, formInputClassName)}
|
||||
value={booleanRadioValue}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<Radio value={true} className='!mr-1'>True</Radio>
|
||||
<Radio value={false}>False</Radio>
|
||||
<Radio value={1} className='m-0 h-7 flex-1 justify-center p-0'>True</Radio>
|
||||
<Radio value={0} className='m-0 h-7 flex-1 justify-center p-0'>False</Radio>
|
||||
</Radio.Group>
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.switch && (
|
||||
<Switch
|
||||
defaultValue={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{fieldState?.validateStatus && [FormItemValidateStatusEnum.Error, FormItemValidateStatusEnum.Warning].includes(fieldState?.validateStatus) && (
|
||||
<div className={cn(
|
||||
'system-xs-regular mt-1 px-0 py-[2px]',
|
||||
@ -339,6 +572,11 @@ const BaseField = ({
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
selfProps?.withBottomDivider && (
|
||||
<div className='h-px w-full bg-divider-subtle' />
|
||||
)
|
||||
}
|
||||
</>
|
||||
|
||||
)
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
AnyFieldApi,
|
||||
AnyFormApi,
|
||||
@ -31,6 +32,7 @@ import {
|
||||
useGetFormValues,
|
||||
useGetValidators,
|
||||
} from '@/app/components/base/form/hooks'
|
||||
import { Button } from '@/app/components/base/button'
|
||||
|
||||
export type BaseFormProps = {
|
||||
formSchemas?: FormSchema[]
|
||||
@ -39,6 +41,7 @@ export type BaseFormProps = {
|
||||
ref?: FormRef
|
||||
disabled?: boolean
|
||||
formFromProps?: AnyFormApi
|
||||
onCancel?: () => void
|
||||
onChange?: (field: string, value: any) => void
|
||||
onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void
|
||||
preventDefaultSubmit?: boolean
|
||||
@ -55,10 +58,12 @@ const BaseForm = ({
|
||||
ref,
|
||||
disabled,
|
||||
formFromProps,
|
||||
onCancel,
|
||||
onChange,
|
||||
onSubmit,
|
||||
preventDefaultSubmit = false,
|
||||
}: BaseFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const initialDefaultValues = useMemo(() => {
|
||||
if (defaultValues)
|
||||
return defaultValues
|
||||
@ -82,8 +87,22 @@ const BaseForm = ({
|
||||
const result: Record<string, any> = {}
|
||||
formSchemas.forEach((schema) => {
|
||||
const { show_on } = schema
|
||||
if (show_on?.length) {
|
||||
show_on.forEach((condition) => {
|
||||
const showOn = typeof show_on === 'function' ? show_on(form) : show_on
|
||||
if (showOn?.length) {
|
||||
showOn?.forEach((condition) => {
|
||||
result[condition.variable] = s.values[condition.variable]
|
||||
})
|
||||
}
|
||||
})
|
||||
return result
|
||||
})
|
||||
const moreOnValues = useStore(form.store, (s: any) => {
|
||||
const result: Record<string, any> = {}
|
||||
formSchemas.forEach((schema) => {
|
||||
const { more_on } = schema
|
||||
const moreOn = typeof more_on === 'function' ? more_on(form) : more_on
|
||||
if (moreOn?.length) {
|
||||
moreOn?.forEach((condition) => {
|
||||
result[condition.variable] = s.values[condition.variable]
|
||||
})
|
||||
}
|
||||
@ -135,11 +154,18 @@ const BaseForm = ({
|
||||
const formSchema = formSchemas?.find(schema => schema.name === field.name)
|
||||
|
||||
if (formSchema) {
|
||||
const { more_on = [] } = formSchema
|
||||
const moreOn = typeof more_on === 'function' ? more_on(form) : more_on
|
||||
const more = (moreOn || []).every((condition) => {
|
||||
const conditionValue = moreOnValues[condition.variable]
|
||||
return Array.isArray(condition.value) ? condition.value.includes(conditionValue) : conditionValue === condition.value
|
||||
})
|
||||
|
||||
return (
|
||||
<BaseField
|
||||
field={field}
|
||||
formSchema={formSchema}
|
||||
fieldClassName={fieldClassName ?? formSchema.fieldClassName}
|
||||
fieldClassName={cn(fieldClassName ?? formSchema.fieldClassName, !more ? 'absolute top-[-9999px]' : '')}
|
||||
labelClassName={labelClassName ?? formSchema.labelClassName}
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
inputClassName={inputClassName}
|
||||
@ -151,7 +177,7 @@ const BaseForm = ({
|
||||
}
|
||||
|
||||
return null
|
||||
}, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled, onChange, fieldStates])
|
||||
}, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled, onChange, moreOnValues, fieldStates])
|
||||
|
||||
const renderFieldWrapper = useCallback((formSchema: FormSchema) => {
|
||||
const validators = getValidators(formSchema)
|
||||
@ -159,10 +185,10 @@ const BaseForm = ({
|
||||
name,
|
||||
show_on = [],
|
||||
} = formSchema
|
||||
|
||||
const show = show_on?.every((condition) => {
|
||||
const showOn = typeof show_on === 'function' ? show_on(form) : show_on
|
||||
const show = (showOn || []).every((condition) => {
|
||||
const conditionValue = showOnValues[condition.variable]
|
||||
return conditionValue === condition.value
|
||||
return Array.isArray(condition.value) ? condition.value.includes(conditionValue) : conditionValue === condition.value
|
||||
})
|
||||
|
||||
if (!show)
|
||||
@ -196,6 +222,28 @@ const BaseForm = ({
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{formSchemas.map(renderFieldWrapper)}
|
||||
{
|
||||
onSubmit && (
|
||||
<div className='flex justify-end space-x-2'>
|
||||
{
|
||||
onCancel && (
|
||||
<Button
|
||||
variant='secondary'
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={() => onSubmit(form.getValues())}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import { memo } from 'react'
|
||||
import { BaseForm } from '../../components/base'
|
||||
import type { BaseFormProps } from '../../components/base'
|
||||
|
||||
const VariableForm = ({
|
||||
formSchemas = [],
|
||||
defaultValues,
|
||||
ref,
|
||||
formFromProps,
|
||||
...rest
|
||||
}: BaseFormProps) => {
|
||||
return (
|
||||
<BaseForm
|
||||
ref={ref}
|
||||
formSchemas={formSchemas}
|
||||
defaultValues={defaultValues}
|
||||
formClassName='space-y-3'
|
||||
labelClassName='h-6 flex items-center mb-1 system-sm-medium text-text-secondary'
|
||||
formFromProps={formFromProps}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(VariableForm)
|
||||
@ -15,13 +15,14 @@ export const useCheckValidated = (form: AnyFormApi, FormSchemas: FormSchema[]) =
|
||||
const errorArray = Object.keys(fields).reduce((acc: string[], key: string) => {
|
||||
const currentSchema = FormSchemas.find(schema => schema.name === key)
|
||||
const { show_on = [] } = currentSchema || {}
|
||||
const showOnValues = show_on.reduce((acc, condition) => {
|
||||
const showOn = typeof show_on === 'function' ? show_on(form) : show_on
|
||||
const showOnValues = (showOn || []).reduce((acc, condition) => {
|
||||
acc[condition.variable] = values[condition.variable]
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
const show = show_on?.every((condition) => {
|
||||
const show = (showOn || []).every((condition) => {
|
||||
const conditionValue = showOnValues[condition.variable]
|
||||
return conditionValue === condition.value
|
||||
return Array.isArray(condition.value) ? condition.value.includes(conditionValue) : conditionValue === condition.value
|
||||
})
|
||||
const errors: any[] = show ? fields[key].errors : []
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ export type TypeWithI18N<T = string> = {
|
||||
|
||||
export type FormShowOnObject = {
|
||||
variable: string
|
||||
value: string
|
||||
value: string | string[]
|
||||
}
|
||||
|
||||
export enum FormTypeEnum {
|
||||
@ -33,7 +33,17 @@ export enum FormTypeEnum {
|
||||
multiToolSelector = 'array[tools]',
|
||||
appSelector = 'app-selector',
|
||||
dynamicSelect = 'dynamic-select',
|
||||
textareaInput = 'textarea-input',
|
||||
promptInput = 'prompt-input',
|
||||
objectList = 'object-list',
|
||||
arrayList = 'array-list',
|
||||
jsonInput = 'json-input',
|
||||
collapse = 'collapse',
|
||||
editMode = 'edit-mode',
|
||||
boolean = 'boolean',
|
||||
booleanList = 'boolean-list',
|
||||
switch = 'switch',
|
||||
nodeSelector = 'node-selector', // used in memory variable form
|
||||
}
|
||||
|
||||
export type FormOption = {
|
||||
@ -53,7 +63,7 @@ export enum FormItemValidateStatusEnum {
|
||||
}
|
||||
|
||||
export type FormSchema = {
|
||||
type: FormTypeEnum
|
||||
type: FormTypeEnum | ((form: AnyFormApi) => FormTypeEnum)
|
||||
name: string
|
||||
label: string | ReactNode | TypeWithI18N | Record<Locale, string>
|
||||
required: boolean
|
||||
@ -61,15 +71,20 @@ export type FormSchema = {
|
||||
default?: any
|
||||
description?: string | TypeWithI18N | Record<Locale, string>
|
||||
tooltip?: string | TypeWithI18N | Record<Locale, string>
|
||||
show_on?: FormShowOnObject[]
|
||||
show_on?: FormShowOnObject[] | ((form: AnyFormApi) => FormShowOnObject[])
|
||||
more_on?: FormShowOnObject[] | ((form: AnyFormApi) => FormShowOnObject[])
|
||||
url?: string
|
||||
scope?: string
|
||||
help?: string | TypeWithI18N | Record<Locale, string>
|
||||
placeholder?: string | TypeWithI18N | Record<Locale, string>
|
||||
options?: FormOption[]
|
||||
labelClassName?: string
|
||||
fieldClassName?: string
|
||||
labelClassName?: string
|
||||
inputContainerClassName?: string
|
||||
inputClassName?: string
|
||||
validators?: AnyValidators
|
||||
selfFormProps?: ((form: AnyFormApi) => Record<string, any>) | Record<string, any>
|
||||
onChange?: (form: AnyFormApi, v: any) => void
|
||||
showRadioUI?: boolean
|
||||
disabled?: boolean
|
||||
showCopy?: boolean
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.678 1.6502C11.1023 1.50885 11.5679 1.56398 11.9473 1.80108L14.2947 3.26885C14.7333 3.54295 15 4.02396 15 4.54107V5.62505L15.9001 6.30035C16.2777 6.58359 16.5 7.02807 16.5 7.50005V9.75005C16.5 10.222 16.2777 10.6665 15.9001 10.9498L15 11.6251V13.4598C14.9999 13.9767 14.7336 14.4572 14.2954 14.7313L11.9473 16.199C11.6152 16.4066 11.217 16.4748 10.8384 16.3939L10.678 16.3499L9 15.7903L7.32202 16.3499C6.89768 16.4913 6.43213 16.4362 6.05273 16.199L3.70532 14.7313C3.2672 14.4572 3.00013 13.9768 3 13.4598V11.6251L2.09985 10.9498C1.72225 10.6665 1.5 10.222 1.5 9.75005V7.50005C1.50004 7.02809 1.72231 6.5836 2.09985 6.30035L3 5.62505V4.54107C3.00005 4.02394 3.26679 3.54294 3.70532 3.26885L6.05273 1.80108C6.43204 1.56403 6.89766 1.50884 7.32202 1.6502L9 2.20977L10.678 1.6502ZM9.75 3.54058V5.68951L10.8625 6.80206C10.9863 6.76904 11.1159 6.75005 11.25 6.75005C12.0784 6.75005 12.75 7.42165 12.75 8.25005C12.75 9.07848 12.0784 9.75005 11.25 9.75005C10.4216 9.75005 9.75 9.07848 9.75 8.25005C9.75001 8.11594 9.76898 7.98631 9.802 7.8626L8.68945 6.75005C8.40829 6.46885 8.25003 6.08736 8.25 5.68951V3.54058L6.84814 3.0733L4.5 4.54107V5.62505C4.5 6.09705 4.27767 6.54146 3.90015 6.82476L3 7.50005V9.75005L3.90015 10.4253C4.27764 10.7086 4.49996 11.1531 4.5 11.6251V13.459L6.84814 14.9268L8.25 14.4588V12.3106L7.13672 11.1973C7.01316 11.2303 6.88394 11.2501 6.75 11.2501C5.92157 11.2501 5.25 10.5785 5.25 9.75005C5.25003 8.92165 5.92159 8.25005 6.75 8.25005C7.57841 8.25005 8.24997 8.92165 8.25 9.75005C8.25 9.88396 8.23019 10.0132 8.19727 10.1368L9.31055 11.2501C9.59176 11.5313 9.74996 11.9128 9.75 12.3106V14.4588L11.1519 14.9268L13.5 13.459V11.6251C13.5 11.153 13.7224 10.7086 14.0999 10.4253L15 9.75005V7.50005L14.0999 6.82476C13.7224 6.54147 13.5 6.09707 13.5 5.62505V4.54107L11.1519 3.0733L9.75 3.54058Z" fill="#354052"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@ -0,0 +1,28 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "18",
|
||||
"height": "18",
|
||||
"viewBox": "0 0 18 18",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M10.678 1.6502C11.1023 1.50885 11.5679 1.56398 11.9473 1.80108L14.2947 3.26885C14.7333 3.54295 15 4.02396 15 4.54107V5.62505L15.9001 6.30035C16.2777 6.58359 16.5 7.02807 16.5 7.50005V9.75005C16.5 10.222 16.2777 10.6665 15.9001 10.9498L15 11.6251V13.4598C14.9999 13.9767 14.7336 14.4572 14.2954 14.7313L11.9473 16.199C11.6152 16.4066 11.217 16.4748 10.8384 16.3939L10.678 16.3499L9 15.7903L7.32202 16.3499C6.89768 16.4913 6.43213 16.4362 6.05273 16.199L3.70532 14.7313C3.2672 14.4572 3.00013 13.9768 3 13.4598V11.6251L2.09985 10.9498C1.72225 10.6665 1.5 10.222 1.5 9.75005V7.50005C1.50004 7.02809 1.72231 6.5836 2.09985 6.30035L3 5.62505V4.54107C3.00005 4.02394 3.26679 3.54294 3.70532 3.26885L6.05273 1.80108C6.43204 1.56403 6.89766 1.50884 7.32202 1.6502L9 2.20977L10.678 1.6502ZM9.75 3.54058V5.68951L10.8625 6.80206C10.9863 6.76904 11.1159 6.75005 11.25 6.75005C12.0784 6.75005 12.75 7.42165 12.75 8.25005C12.75 9.07848 12.0784 9.75005 11.25 9.75005C10.4216 9.75005 9.75 9.07848 9.75 8.25005C9.75001 8.11594 9.76898 7.98631 9.802 7.8626L8.68945 6.75005C8.40829 6.46885 8.25003 6.08736 8.25 5.68951V3.54058L6.84814 3.0733L4.5 4.54107V5.62505C4.5 6.09705 4.27767 6.54146 3.90015 6.82476L3 7.50005V9.75005L3.90015 10.4253C4.27764 10.7086 4.49996 11.1531 4.5 11.6251V13.459L6.84814 14.9268L8.25 14.4588V12.3106L7.13672 11.1973C7.01316 11.2303 6.88394 11.2501 6.75 11.2501C5.92157 11.2501 5.25 10.5785 5.25 9.75005C5.25003 8.92165 5.92159 8.25005 6.75 8.25005C7.57841 8.25005 8.24997 8.92165 8.25 9.75005C8.25 9.88396 8.23019 10.0132 8.19727 10.1368L9.31055 11.2501C9.59176 11.5313 9.74996 11.9128 9.75 12.3106V14.4588L11.1519 14.9268L13.5 13.459V11.6251C13.5 11.153 13.7224 10.7086 14.0999 10.4253L15 9.75005V7.50005L14.0999 6.82476C13.7224 6.54147 13.5 6.09707 13.5 5.62505V4.54107L11.1519 3.0733L9.75 3.54058Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Memory"
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import * as React from 'react'
|
||||
import data from './Memory.json'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Memory'
|
||||
|
||||
export default Icon
|
||||
@ -6,5 +6,6 @@ export { default as GlobalVariable } from './GlobalVariable'
|
||||
export { default as Icon3Dots } from './Icon3Dots'
|
||||
export { default as LongArrowLeft } from './LongArrowLeft'
|
||||
export { default as LongArrowRight } from './LongArrowRight'
|
||||
export { default as Memory } from './Memory'
|
||||
export { default as SearchMenu } from './SearchMenu'
|
||||
export { default as Tools } from './Tools'
|
||||
|
||||
@ -10,6 +10,7 @@ export const LAST_RUN_PLACEHOLDER_TEXT = '{{#last_run#}}'
|
||||
export const PRE_PROMPT_PLACEHOLDER_TEXT = '{{#pre_prompt#}}'
|
||||
export const UPDATE_DATASETS_EVENT_EMITTER = 'prompt-editor-context-block-update-datasets'
|
||||
export const UPDATE_HISTORY_EVENT_EMITTER = 'prompt-editor-history-block-update-role'
|
||||
export const UPDATE_WORKFLOW_VARIABLES_EVENT_EMITTER = 'prompt-editor-workflow-variables-block-update-variables'
|
||||
|
||||
export const checkHasContextBlock = (text: string) => {
|
||||
if (!text)
|
||||
|
||||
@ -61,6 +61,8 @@ import { VariableValueBlockNode } from './plugins/variable-value-block/node'
|
||||
import { CustomTextNode } from './plugins/custom-text/node'
|
||||
import OnBlurBlock from './plugins/on-blur-or-focus-block'
|
||||
import UpdateBlock from './plugins/update-block'
|
||||
import MemoryPopupPlugin from './plugins/memory-popup-plugin'
|
||||
|
||||
import { textToEditorState } from './utils'
|
||||
import type {
|
||||
ContextBlockType,
|
||||
@ -76,9 +78,11 @@ import type {
|
||||
import {
|
||||
UPDATE_DATASETS_EVENT_EMITTER,
|
||||
UPDATE_HISTORY_EVENT_EMITTER,
|
||||
UPDATE_WORKFLOW_VARIABLES_EVENT_EMITTER,
|
||||
} from './constants'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import cn from '@/utils/classnames'
|
||||
import PromptEditorProvider from './store/provider'
|
||||
|
||||
export type PromptEditorProps = {
|
||||
instanceId?: string
|
||||
@ -174,6 +178,13 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
payload: historyBlock?.history,
|
||||
} as any)
|
||||
}, [eventEmitter, historyBlock?.history])
|
||||
useEffect(() => {
|
||||
eventEmitter?.emit({
|
||||
type: UPDATE_WORKFLOW_VARIABLES_EVENT_EMITTER,
|
||||
payload: workflowVariableBlock?.variables,
|
||||
instanceId,
|
||||
} as any)
|
||||
}, [eventEmitter, workflowVariableBlock?.variables])
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={{ ...initialConfig, editable }}>
|
||||
@ -198,6 +209,12 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
{workflowVariableBlock?.show && workflowVariableBlock?.isMemorySupported && (
|
||||
<MemoryPopupPlugin
|
||||
instanceId={instanceId}
|
||||
memoryVariables={workflowVariableBlock?.variables?.find(v => v.nodeId === 'memory_block')?.vars || []}
|
||||
/>
|
||||
)}
|
||||
<ComponentPickerBlock
|
||||
triggerString='/'
|
||||
contextBlock={contextBlock}
|
||||
@ -303,4 +320,12 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default PromptEditor
|
||||
const PromptEditorWithProvider = ({ instanceId, ...props }: PromptEditorProps) => {
|
||||
return (
|
||||
<PromptEditorProvider instanceId={instanceId}>
|
||||
<PromptEditor {...props} instanceId={instanceId} />
|
||||
</PromptEditorProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default PromptEditorWithProvider
|
||||
|
||||
@ -283,7 +283,19 @@ export const useOptions = (
|
||||
const workflowVariableOptions = useMemo(() => {
|
||||
if (!workflowVariableBlockType?.show)
|
||||
return []
|
||||
const res = workflowVariableBlockType.variables || []
|
||||
let res = workflowVariableBlockType.variables || []
|
||||
|
||||
if (!workflowVariableBlockType.isMemorySupported) {
|
||||
res = res.map((v) => {
|
||||
if (v.nodeId === 'conversation') {
|
||||
return {
|
||||
...v,
|
||||
vars: v.vars.filter(vv => !vv.variable.startsWith('memory_block.')),
|
||||
}
|
||||
}
|
||||
return v
|
||||
})
|
||||
}
|
||||
if(errorMessageBlockType?.show && res.findIndex(v => v.nodeId === 'error_message') === -1) {
|
||||
res.unshift({
|
||||
nodeId: 'error_message',
|
||||
|
||||
@ -0,0 +1,273 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
size,
|
||||
useFloating,
|
||||
} from '@floating-ui/react'
|
||||
import {
|
||||
RiAddLine,
|
||||
} from '@remixicon/react'
|
||||
import { Memory } from '@/app/components/base/icons/src/vender/line/others'
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
} from 'lexical'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { MEMORY_POPUP_SHOW_BY_EVENT_EMITTER, MEMORY_VAR_CREATED_BY_MODAL_BY_EVENT_EMITTER, MEMORY_VAR_MODAL_SHOW_BY_EVENT_EMITTER } from '@/app/components/workflow/nodes/_base/components/prompt/type'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import VariableIcon from '@/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-icon'
|
||||
import type {
|
||||
Var,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND } from '../workflow-variable-block'
|
||||
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export type MemoryPopupProps = {
|
||||
className?: string
|
||||
container?: Element | null
|
||||
instanceId?: string
|
||||
memoryVariables: Var[]
|
||||
}
|
||||
|
||||
export default function MemoryPopupPlugin({
|
||||
className,
|
||||
container,
|
||||
instanceId,
|
||||
memoryVariables,
|
||||
}: MemoryPopupProps) {
|
||||
const { t } = useTranslation()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const portalRef = useRef<HTMLDivElement | null>(null)
|
||||
const lastSelectionRef = useRef<Range | null>(null)
|
||||
|
||||
const containerEl = useMemo(() => container ?? (typeof document !== 'undefined' ? document.body : null), [container])
|
||||
|
||||
const useContainer = !!containerEl && containerEl !== document.body
|
||||
const memoryVarInNode = memoryVariables.filter(memoryVariable => memoryVariable.memoryVariableNodeId)
|
||||
const memoryVarInApp = memoryVariables.filter(memoryVariable => !memoryVariable.memoryVariableNodeId)
|
||||
|
||||
const { refs, floatingStyles, isPositioned } = useFloating({
|
||||
placement: 'bottom-start',
|
||||
middleware: [
|
||||
offset(0), // fix hide cursor
|
||||
shift({
|
||||
padding: 8,
|
||||
altBoundary: true,
|
||||
}),
|
||||
flip(),
|
||||
size({
|
||||
apply({ availableWidth, availableHeight, elements }) {
|
||||
Object.assign(elements.floating.style, {
|
||||
maxWidth: `${Math.min(400, availableWidth)}px`,
|
||||
maxHeight: `${Math.min(300, availableHeight)}px`,
|
||||
overflow: 'auto',
|
||||
})
|
||||
},
|
||||
padding: 8,
|
||||
}),
|
||||
],
|
||||
whileElementsMounted: autoUpdate,
|
||||
})
|
||||
|
||||
const openPortal = useCallback(() => {
|
||||
const domSelection = window.getSelection()
|
||||
let range: Range | null = null
|
||||
if (domSelection && domSelection.rangeCount > 0)
|
||||
range = domSelection.getRangeAt(0).cloneRange()
|
||||
else
|
||||
range = lastSelectionRef.current
|
||||
|
||||
if (range) {
|
||||
const rects = range.getClientRects()
|
||||
let rect: DOMRect | null = null
|
||||
|
||||
if (rects && rects.length)
|
||||
rect = rects[rects.length - 1]
|
||||
|
||||
else
|
||||
rect = range.getBoundingClientRect()
|
||||
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
const root = editor.getRootElement()
|
||||
if (root) {
|
||||
const sc = range.startContainer
|
||||
const node = sc.nodeType === Node.ELEMENT_NODE
|
||||
? sc as Element
|
||||
: (sc.parentElement || root)
|
||||
|
||||
rect = node.getBoundingClientRect()
|
||||
|
||||
if (rect.width === 0 && rect.height === 0)
|
||||
rect = root.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
|
||||
if (rect && !(rect.top === 0 && rect.left === 0 && rect.width === 0 && rect.height === 0)) {
|
||||
const virtualEl = {
|
||||
getBoundingClientRect() {
|
||||
return rect!
|
||||
},
|
||||
}
|
||||
refs.setReference(virtualEl as Element)
|
||||
}
|
||||
}
|
||||
|
||||
setOpen(true)
|
||||
}, [setOpen])
|
||||
|
||||
const closePortal = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [setOpen])
|
||||
|
||||
const handleSelectVariable = useCallback((variable: string[]) => {
|
||||
editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variable)
|
||||
closePortal()
|
||||
}, [editor, closePortal])
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
eventEmitter?.emit({ type: MEMORY_VAR_MODAL_SHOW_BY_EVENT_EMITTER, instanceId } as any)
|
||||
closePortal()
|
||||
}, [eventEmitter, instanceId, closePortal])
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v.type === MEMORY_POPUP_SHOW_BY_EVENT_EMITTER && v.instanceId === instanceId)
|
||||
openPortal()
|
||||
})
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v.type === MEMORY_VAR_CREATED_BY_MODAL_BY_EVENT_EMITTER && v.instanceId === instanceId)
|
||||
handleSelectVariable(v.variable)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
const domSelection = window.getSelection()
|
||||
if (domSelection && domSelection.rangeCount > 0)
|
||||
lastSelectionRef.current = domSelection.getRangeAt(0).cloneRange()
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (!portalRef.current)
|
||||
return
|
||||
if (!portalRef.current.contains(e.target as Node))
|
||||
closePortal()
|
||||
}
|
||||
document.addEventListener('mousedown', onMouseDown, false)
|
||||
return () => document.removeEventListener('mousedown', onMouseDown, false)
|
||||
}, [open, closePortal])
|
||||
|
||||
if (!open || !containerEl)
|
||||
return null
|
||||
|
||||
return createPortal(
|
||||
<div className='h-0 w-0'>
|
||||
<div
|
||||
ref={(node) => {
|
||||
portalRef.current = node
|
||||
refs.setFloating(node)
|
||||
}}
|
||||
className={cn(
|
||||
useContainer ? '' : 'z-[999999]',
|
||||
'absolute rounded-xl shadow-lg backdrop-blur-sm',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
visibility: isPositioned ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className='w-[261px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur'>
|
||||
{memoryVarInNode.length > 0 && (
|
||||
<>
|
||||
<div className='flex items-center gap-1 pb-1 pt-2.5'>
|
||||
<Divider className='!h-px !w-3 bg-divider-subtle' />
|
||||
<div className='system-2xs-medium-uppercase shrink-0 text-text-tertiary'>{t('workflow.nodes.llm.memory.currentNodeLabel')}</div>
|
||||
<Divider className='!h-px grow bg-divider-subtle' />
|
||||
</div>
|
||||
<div className='p-1'>
|
||||
{memoryVarInNode.map(variable => (
|
||||
<div
|
||||
key={variable.variable}
|
||||
className='flex cursor-pointer items-center gap-1 rounded-md px-3 py-1 hover:bg-state-base-hover'
|
||||
onClick={() => handleSelectVariable(['memory_block', variable.variable])}
|
||||
>
|
||||
<VariableIcon
|
||||
variables={['memory_block', '']}
|
||||
className='text-util-colors-teal-teal-700'
|
||||
/>
|
||||
<div title={variable.memoryVariableName} className='system-sm-medium shrink-0 truncate text-text-secondary'>{variable.memoryVariableName}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{memoryVarInApp.length > 0 && (
|
||||
<>
|
||||
<div className='flex items-center gap-1 pb-1 pt-2.5'>
|
||||
<Divider className='!h-px !w-3 bg-divider-subtle' />
|
||||
<div className='system-2xs-medium-uppercase shrink-0 text-text-tertiary'>{t('workflow.nodes.llm.memory.conversationScopeLabel')}</div>
|
||||
<Divider className='!h-px grow bg-divider-subtle' />
|
||||
</div>
|
||||
<div className='p-1'>
|
||||
{memoryVarInApp.map(variable => (
|
||||
<div
|
||||
key={variable.variable}
|
||||
className='flex cursor-pointer items-center gap-1 rounded-md px-3 py-1 hover:bg-state-base-hover'
|
||||
onClick={() => handleSelectVariable(['memory_block', variable.variable])}
|
||||
>
|
||||
<VariableIcon
|
||||
variables={['memory_block', '']}
|
||||
className='text-util-colors-teal-teal-700'
|
||||
/>
|
||||
<div title={variable.variable} className='system-sm-medium shrink-0 truncate text-text-secondary'>{variable.memoryVariableName}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!memoryVarInNode.length && !memoryVarInApp.length && (
|
||||
<div className='p-2'>
|
||||
<div className='flex flex-col gap-2 rounded-[10px] bg-workflow-process-bg p-4'>
|
||||
<div className='flex h-10 w-10 items-center justify-center rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-sm'>
|
||||
<Memory className='h-5 w-5 text-util-colors-teal-teal-700' />
|
||||
</div>
|
||||
<div className='system-sm-medium text-text-secondary'>{t('workflow.nodes.llm.memory.emptyState')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='system-xs-medium flex cursor-pointer items-center gap-1 border-t border-divider-subtle px-4 py-2 text-text-accent-light-mode-only' onClick={handleCreate}>
|
||||
<RiAddLine className='h-4 w-4' />
|
||||
<div>{t('workflow.nodes.llm.memory.createButton')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
containerEl,
|
||||
)
|
||||
}
|
||||
@ -1,8 +1,11 @@
|
||||
import { $insertNodes } from 'lexical'
|
||||
import {
|
||||
$insertNodes,
|
||||
} from 'lexical'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { textToEditorState } from '../utils'
|
||||
import { CustomTextNode } from './custom-text/node'
|
||||
import { CLEAR_HIDE_MENU_TIMEOUT } from './workflow-variable-block'
|
||||
import { MEMORY_POPUP_SHOW_BY_EVENT_EMITTER } from '@/app/components/workflow/nodes/_base/components/prompt/type'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
|
||||
export const PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER = 'PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER'
|
||||
@ -36,6 +39,18 @@ const UpdateBlock = ({
|
||||
}
|
||||
})
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v.type === MEMORY_POPUP_SHOW_BY_EVENT_EMITTER && v.instanceId === instanceId) {
|
||||
editor.focus()
|
||||
editor.update(() => {
|
||||
const textNode = new CustomTextNode('')
|
||||
$insertNodes([textNode])
|
||||
|
||||
editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@ -19,23 +19,27 @@ import {
|
||||
DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND,
|
||||
UPDATE_WORKFLOW_NODES_MAP,
|
||||
} from './index'
|
||||
import { isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
|
||||
import { isConversationVar, isENV, isGlobalVar, isMemoryVariable, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { isExceptionVariable } from '@/app/components/workflow/utils'
|
||||
import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import type {
|
||||
NodeOutPutVar,
|
||||
ValueSelector,
|
||||
} from '@/app/components/workflow/types'
|
||||
import {
|
||||
VariableLabelInEditor,
|
||||
} from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { UPDATE_WORKFLOW_VARIABLES_EVENT_EMITTER } from '../../constants'
|
||||
import { usePromptEditorStore } from '../../store/store'
|
||||
|
||||
type WorkflowVariableBlockComponentProps = {
|
||||
nodeKey: string
|
||||
variables: string[]
|
||||
workflowNodesMap: WorkflowNodesMap
|
||||
environmentVariables?: Var[]
|
||||
conversationVariables?: Var[]
|
||||
ragVariables?: Var[]
|
||||
availableVariables: NodeOutPutVar[]
|
||||
getVarType?: (payload: {
|
||||
nodeId: string,
|
||||
valueSelector: ValueSelector,
|
||||
@ -47,12 +51,27 @@ const WorkflowVariableBlockComponent = ({
|
||||
variables,
|
||||
workflowNodesMap = {},
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
availableVariables: initialAvailableVariables,
|
||||
}: WorkflowVariableBlockComponentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const instanceId = usePromptEditorStore(s => s.instanceId)
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const [availableVariables, setAvailableVariables] = useState<NodeOutPutVar[]>(initialAvailableVariables)
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v?.type === UPDATE_WORKFLOW_VARIABLES_EVENT_EMITTER && instanceId && v.instanceId === instanceId)
|
||||
setAvailableVariables(v.payload)
|
||||
})
|
||||
const environmentVariables = availableVariables?.find(v => v.nodeId === 'env')?.vars || []
|
||||
const conversationVariables = availableVariables?.find(v => v.nodeId === 'conversation')?.vars || []
|
||||
const memoryVariables = conversationVariables?.filter(v => v.variable.startsWith('memory_block.'))
|
||||
const ragVariables = availableVariables?.reduce<any[]>((acc, curr) => {
|
||||
if (curr.nodeId === 'rag')
|
||||
acc.push(...curr.vars)
|
||||
else
|
||||
acc.push(...curr.vars.filter(v => v.isRagVariable))
|
||||
return acc
|
||||
}, [])
|
||||
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND)
|
||||
const variablesLength = variables.length
|
||||
const isRagVar = isRagVariableVar(variables)
|
||||
@ -72,21 +91,23 @@ const WorkflowVariableBlockComponent = ({
|
||||
let variableValid = true
|
||||
const isEnv = isENV(variables)
|
||||
const isChatVar = isConversationVar(variables)
|
||||
const isMemoryVar = isMemoryVariable(variables)
|
||||
const isGlobal = isGlobalVar(variables)
|
||||
if (isGlobal)
|
||||
return true
|
||||
|
||||
if (isEnv) {
|
||||
if (environmentVariables)
|
||||
variableValid = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
|
||||
variableValid
|
||||
= environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
|
||||
}
|
||||
else if (isChatVar) {
|
||||
if (conversationVariables)
|
||||
variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
|
||||
variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
|
||||
}
|
||||
else if (isMemoryVar) {
|
||||
variableValid = memoryVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
|
||||
}
|
||||
else if (isRagVar) {
|
||||
if (ragVariables)
|
||||
variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`)
|
||||
variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`)
|
||||
}
|
||||
else {
|
||||
variableValid = !!node
|
||||
@ -134,11 +155,28 @@ const WorkflowVariableBlockComponent = ({
|
||||
})
|
||||
}, [node, reactflow, store])
|
||||
|
||||
const memoriedVariables = useMemo(() => {
|
||||
if (variables[0] === 'memory_block') {
|
||||
const currentMemoryVariable = memoryVariables?.find(v => v.variable === variables.join('.'))
|
||||
|
||||
if (currentMemoryVariable && currentMemoryVariable.memoryVariableName) {
|
||||
return [
|
||||
'memory_block',
|
||||
currentMemoryVariable.memoryVariableName,
|
||||
]
|
||||
}
|
||||
|
||||
return variables
|
||||
}
|
||||
|
||||
return variables
|
||||
}, [memoryVariables, variables])
|
||||
|
||||
const Item = (
|
||||
<VariableLabelInEditor
|
||||
nodeType={node?.type}
|
||||
nodeTitle={node?.title}
|
||||
variables={variables}
|
||||
variables={memoriedVariables}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleVariableJump()
|
||||
@ -152,7 +190,7 @@ const WorkflowVariableBlockComponent = ({
|
||||
)
|
||||
|
||||
if (!node)
|
||||
return Item
|
||||
return <div>{Item}</div>
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
@ -160,10 +198,10 @@ const WorkflowVariableBlockComponent = ({
|
||||
popupContent={
|
||||
<VarFullPathPanel
|
||||
nodeName={node.title}
|
||||
path={variables.slice(1)}
|
||||
path={memoriedVariables.slice(1)}
|
||||
varType={getVarType ? getVarType({
|
||||
nodeId: variables[0],
|
||||
valueSelector: variables,
|
||||
nodeId: memoriedVariables[0],
|
||||
valueSelector: memoriedVariables,
|
||||
}) : Type.string}
|
||||
nodeType={node?.type}
|
||||
/>}
|
||||
|
||||
@ -32,6 +32,7 @@ const WorkflowVariableBlock = memo(({
|
||||
onInsert,
|
||||
onDelete,
|
||||
getVarType,
|
||||
variables: originalVariables,
|
||||
}: WorkflowVariableBlockType) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
@ -50,7 +51,7 @@ const WorkflowVariableBlock = memo(({
|
||||
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
|
||||
(variables: string[]) => {
|
||||
editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
|
||||
const workflowVariableBlockNode = $createWorkflowVariableBlockNode(variables, workflowNodesMap, getVarType)
|
||||
const workflowVariableBlockNode = $createWorkflowVariableBlockNode(variables, workflowNodesMap, getVarType, originalVariables || [])
|
||||
|
||||
$insertNodes([workflowVariableBlockNode])
|
||||
if (onInsert)
|
||||
|
||||
@ -3,7 +3,7 @@ import { DecoratorNode } from 'lexical'
|
||||
import type { WorkflowVariableBlockType } from '../../types'
|
||||
import WorkflowVariableBlockComponent from './component'
|
||||
import type { GetVarType } from '../../types'
|
||||
import type { Var } from '@/app/components/workflow/types'
|
||||
import type { NodeOutPutVar } from '@/app/components/workflow/types'
|
||||
|
||||
export type WorkflowNodesMap = WorkflowVariableBlockType['workflowNodesMap']
|
||||
|
||||
@ -11,40 +11,40 @@ export type SerializedNode = SerializedLexicalNode & {
|
||||
variables: string[]
|
||||
workflowNodesMap: WorkflowNodesMap
|
||||
getVarType?: GetVarType
|
||||
environmentVariables?: Var[]
|
||||
conversationVariables?: Var[]
|
||||
ragVariables?: Var[]
|
||||
availableVariables?: NodeOutPutVar[]
|
||||
}
|
||||
|
||||
export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element> {
|
||||
__variables: string[]
|
||||
__workflowNodesMap: WorkflowNodesMap
|
||||
__getVarType?: GetVarType
|
||||
__environmentVariables?: Var[]
|
||||
__conversationVariables?: Var[]
|
||||
__ragVariables?: Var[]
|
||||
__availableVariables?: NodeOutPutVar[]
|
||||
|
||||
static getType(): string {
|
||||
return 'workflow-variable-block'
|
||||
}
|
||||
|
||||
static clone(node: WorkflowVariableBlockNode): WorkflowVariableBlockNode {
|
||||
return new WorkflowVariableBlockNode(node.__variables, node.__workflowNodesMap, node.__getVarType, node.__key, node.__environmentVariables, node.__conversationVariables, node.__ragVariables)
|
||||
return new WorkflowVariableBlockNode(node.__variables, node.__workflowNodesMap, node.__getVarType, node.__key, node.__availableVariables)
|
||||
}
|
||||
|
||||
isInline(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
constructor(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType: any, key?: NodeKey, environmentVariables?: Var[], conversationVariables?: Var[], ragVariables?: Var[]) {
|
||||
constructor(
|
||||
variables: string[],
|
||||
workflowNodesMap: WorkflowNodesMap,
|
||||
getVarType: any,
|
||||
key?: NodeKey,
|
||||
availableVariables?: NodeOutPutVar[],
|
||||
) {
|
||||
super(key)
|
||||
|
||||
this.__variables = variables
|
||||
this.__workflowNodesMap = workflowNodesMap
|
||||
this.__getVarType = getVarType
|
||||
this.__environmentVariables = environmentVariables
|
||||
this.__conversationVariables = conversationVariables
|
||||
this.__ragVariables = ragVariables
|
||||
this.__availableVariables = availableVariables
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
@ -64,15 +64,13 @@ export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element>
|
||||
variables={this.__variables}
|
||||
workflowNodesMap={this.__workflowNodesMap}
|
||||
getVarType={this.__getVarType!}
|
||||
environmentVariables={this.__environmentVariables}
|
||||
conversationVariables={this.__conversationVariables}
|
||||
ragVariables={this.__ragVariables}
|
||||
availableVariables={this.__availableVariables || []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedNode): WorkflowVariableBlockNode {
|
||||
const node = $createWorkflowVariableBlockNode(serializedNode.variables, serializedNode.workflowNodesMap, serializedNode.getVarType, serializedNode.environmentVariables, serializedNode.conversationVariables, serializedNode.ragVariables)
|
||||
const node = $createWorkflowVariableBlockNode(serializedNode.variables, serializedNode.workflowNodesMap, serializedNode.getVarType, serializedNode.availableVariables)
|
||||
|
||||
return node
|
||||
}
|
||||
@ -84,9 +82,7 @@ export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element>
|
||||
variables: this.getVariables(),
|
||||
workflowNodesMap: this.getWorkflowNodesMap(),
|
||||
getVarType: this.getVarType(),
|
||||
environmentVariables: this.getEnvironmentVariables(),
|
||||
conversationVariables: this.getConversationVariables(),
|
||||
ragVariables: this.getRagVariables(),
|
||||
availableVariables: this.getAvailableVariables(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -105,27 +101,17 @@ export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element>
|
||||
return self.__getVarType
|
||||
}
|
||||
|
||||
getEnvironmentVariables(): any {
|
||||
getAvailableVariables(): NodeOutPutVar[] {
|
||||
const self = this.getLatest()
|
||||
return self.__environmentVariables
|
||||
}
|
||||
|
||||
getConversationVariables(): any {
|
||||
const self = this.getLatest()
|
||||
return self.__conversationVariables
|
||||
}
|
||||
|
||||
getRagVariables(): any {
|
||||
const self = this.getLatest()
|
||||
return self.__ragVariables
|
||||
return self.__availableVariables || []
|
||||
}
|
||||
|
||||
getTextContent(): string {
|
||||
return `{{#${this.getVariables().join('.')}#}}`
|
||||
}
|
||||
}
|
||||
export function $createWorkflowVariableBlockNode(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType?: GetVarType, environmentVariables?: Var[], conversationVariables?: Var[], ragVariables?: Var[]): WorkflowVariableBlockNode {
|
||||
return new WorkflowVariableBlockNode(variables, workflowNodesMap, getVarType, undefined, environmentVariables, conversationVariables, ragVariables)
|
||||
export function $createWorkflowVariableBlockNode(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType?: GetVarType, availableVariables?: NodeOutPutVar[]): WorkflowVariableBlockNode {
|
||||
return new WorkflowVariableBlockNode(variables, workflowNodesMap, getVarType, undefined, availableVariables)
|
||||
}
|
||||
|
||||
export function $isWorkflowVariableBlockNode(
|
||||
|
||||
@ -21,13 +21,6 @@ const WorkflowVariableBlockReplacementBlock = ({
|
||||
variables,
|
||||
}: WorkflowVariableBlockType) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const ragVariables = variables?.reduce<any[]>((acc, curr) => {
|
||||
if (curr.nodeId === 'rag')
|
||||
acc.push(...curr.vars)
|
||||
else
|
||||
acc.push(...curr.vars.filter(v => v.isRagVariable))
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([WorkflowVariableBlockNode]))
|
||||
@ -39,7 +32,7 @@ const WorkflowVariableBlockReplacementBlock = ({
|
||||
onInsert()
|
||||
|
||||
const nodePathString = textNode.getTextContent().slice(3, -3)
|
||||
return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap, getVarType, variables?.find(o => o.nodeId === 'env')?.vars || [], variables?.find(o => o.nodeId === 'conversation')?.vars || [], ragVariables))
|
||||
return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap, getVarType, variables))
|
||||
}, [onInsert, workflowNodesMap, getVarType, variables])
|
||||
|
||||
const getMatch = useCallback((text: string) => {
|
||||
|
||||
31
web/app/components/base/prompt-editor/store/provider.tsx
Normal file
31
web/app/components/base/prompt-editor/store/provider.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { createContext, useRef } from 'react'
|
||||
import { createPromptEditorStore } from './store'
|
||||
|
||||
type PromptEditorStoreApi = ReturnType<typeof createPromptEditorStore>
|
||||
|
||||
type PromptEditorContextType = PromptEditorStoreApi | undefined
|
||||
|
||||
export const PromptEditorContext = createContext<PromptEditorContextType>(undefined)
|
||||
|
||||
type PromptEditorProviderProps = {
|
||||
instanceId?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const PromptEditorProvider = ({
|
||||
instanceId,
|
||||
children,
|
||||
}: PromptEditorProviderProps) => {
|
||||
const storeRef = useRef<PromptEditorStoreApi>(undefined)
|
||||
|
||||
if (!storeRef.current)
|
||||
storeRef.current = createPromptEditorStore({ instanceId })
|
||||
|
||||
return (
|
||||
<PromptEditorContext.Provider value={storeRef.current!}>
|
||||
{children}
|
||||
</PromptEditorContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default PromptEditorProvider
|
||||
24
web/app/components/base/prompt-editor/store/store.ts
Normal file
24
web/app/components/base/prompt-editor/store/store.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { useContext } from 'react'
|
||||
import { createStore, useStore } from 'zustand'
|
||||
import { PromptEditorContext } from './provider'
|
||||
|
||||
type PromptEditorStoreProps = {
|
||||
instanceId?: string
|
||||
}
|
||||
type PromptEditorStore = {
|
||||
instanceId?: string
|
||||
}
|
||||
|
||||
export const createPromptEditorStore = ({ instanceId }: PromptEditorStoreProps) => {
|
||||
return createStore<PromptEditorStore>(() => ({
|
||||
instanceId,
|
||||
}))
|
||||
}
|
||||
|
||||
export const usePromptEditorStore = <T>(selector: (state: PromptEditorStore) => T): T => {
|
||||
const store = useContext(PromptEditorContext)
|
||||
if (!store)
|
||||
throw new Error('Missing PromptEditorContext.Provider in the tree')
|
||||
|
||||
return useStore(store, selector)
|
||||
}
|
||||
@ -71,6 +71,7 @@ export type WorkflowVariableBlockType = {
|
||||
getVarType?: GetVarType
|
||||
showManageInputField?: boolean
|
||||
onManageInputField?: () => void
|
||||
isMemorySupported?: boolean
|
||||
}
|
||||
|
||||
export type MenuTextMatch = {
|
||||
|
||||
@ -1,79 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { RiArrowRightLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ServiceItem from './service-item'
|
||||
import type { ServiceConnectionPanelProps } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const ServiceConnectionPanel: FC<ServiceConnectionPanelProps> = ({
|
||||
title,
|
||||
description,
|
||||
services,
|
||||
onConnect,
|
||||
onContinue,
|
||||
continueDisabled,
|
||||
continueText,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const allConnected = useMemo(() => {
|
||||
return services.every(service => service.status === 'connected')
|
||||
}, [services])
|
||||
|
||||
const displayTitle = title || t('share.serviceConnection.title')
|
||||
const displayDescription = description || t('share.serviceConnection.description', { count: services.length })
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex w-full max-w-[600px] flex-col items-center',
|
||||
className,
|
||||
)}>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="system-xl-semibold mb-1 text-text-primary">
|
||||
{displayTitle}
|
||||
</h2>
|
||||
<p className="system-sm-regular text-text-tertiary">
|
||||
{displayDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2">
|
||||
{services.map(service => (
|
||||
<ServiceItem
|
||||
key={service.id}
|
||||
service={service}
|
||||
onConnect={onConnect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{onContinue && (
|
||||
<div className="mt-6 flex w-full justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={continueDisabled ?? !allConnected}
|
||||
onClick={onContinue}
|
||||
>
|
||||
{continueText || t('share.serviceConnection.continue')}
|
||||
<RiArrowRightLine className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ServiceConnectionPanel)
|
||||
|
||||
export { default as ServiceItem } from './service-item'
|
||||
export type {
|
||||
ServiceConnectionPanelProps,
|
||||
ServiceConnectionItem,
|
||||
AuthType,
|
||||
ServiceConnectionStatus,
|
||||
} from './types'
|
||||
@ -1,72 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { AuthType, ServiceConnectionItem } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ServiceItemProps = {
|
||||
service: ServiceConnectionItem
|
||||
onConnect: (serviceId: string, authType: AuthType) => void
|
||||
}
|
||||
|
||||
const ServiceItem: FC<ServiceItemProps> = ({
|
||||
service,
|
||||
onConnect,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleConnect = () => {
|
||||
onConnect(service.id, service.authType)
|
||||
}
|
||||
|
||||
const getButtonText = () => {
|
||||
if (service.status === 'connected')
|
||||
return t('share.serviceConnection.connected')
|
||||
|
||||
if (service.authType === 'api_key')
|
||||
return t('share.serviceConnection.addApiKey')
|
||||
|
||||
return t('share.serviceConnection.connect')
|
||||
}
|
||||
|
||||
const isConnected = service.status === 'connected'
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center justify-between gap-3 rounded-xl border border-components-panel-border-subtle bg-components-panel-bg px-4 py-3',
|
||||
'hover:border-components-panel-border hover:shadow-xs',
|
||||
'transition-all duration-200',
|
||||
)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
{service.icon}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="system-sm-medium text-text-secondary">
|
||||
{service.name}
|
||||
</span>
|
||||
{service.description && (
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{service.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={isConnected ? 'secondary' : 'secondary-accent'}
|
||||
size="small"
|
||||
onClick={handleConnect}
|
||||
disabled={isConnected}
|
||||
>
|
||||
{!isConnected && <RiAddLine className="mr-0.5 h-3.5 w-3.5" />}
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ServiceItem)
|
||||
@ -1,25 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type AuthType = 'oauth' | 'api_key'
|
||||
|
||||
export type ServiceConnectionStatus = 'pending' | 'connected' | 'error'
|
||||
|
||||
export type ServiceConnectionItem = {
|
||||
id: string
|
||||
name: string
|
||||
icon: ReactNode
|
||||
authType: AuthType
|
||||
status: ServiceConnectionStatus
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type ServiceConnectionPanelProps = {
|
||||
title?: string
|
||||
description?: string
|
||||
services: ServiceConnectionItem[]
|
||||
onConnect: (serviceId: string, authType: AuthType) => void
|
||||
onContinue?: () => void
|
||||
continueDisabled?: boolean
|
||||
continueText?: string
|
||||
className?: string
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
RiBook2Line,
|
||||
RiFileEditLine,
|
||||
@ -25,8 +25,6 @@ import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/con
|
||||
import { useEducationVerify } from '@/service/use-education'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { Enterprise, Professional, Sandbox, Team } from './assets'
|
||||
import { Loading } from '../../base/icons/src/public/thought'
|
||||
import { useUnmountedRef } from 'ahooks'
|
||||
|
||||
type Props = {
|
||||
loc: string
|
||||
@ -37,7 +35,6 @@ const PlanComp: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const path = usePathname()
|
||||
const { userProfile } = useAppContext()
|
||||
const { plan, enableEducationPlan, allowRefreshEducationVerify, isEducationAccount } = useProviderContext()
|
||||
const isAboutToExpire = allowRefreshEducationVerify
|
||||
@ -64,24 +61,17 @@ const PlanComp: FC<Props> = ({
|
||||
})()
|
||||
|
||||
const [showModal, setShowModal] = React.useState(false)
|
||||
const { mutateAsync, isPending } = useEducationVerify()
|
||||
const { mutateAsync } = useEducationVerify()
|
||||
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
|
||||
const unmountedRef = useUnmountedRef()
|
||||
const handleVerify = () => {
|
||||
if (isPending) return
|
||||
mutateAsync().then((res) => {
|
||||
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
||||
if (unmountedRef.current) return
|
||||
router.push(`/education-apply?token=${res.token}`)
|
||||
setShowAccountSettingModal(null)
|
||||
}).catch(() => {
|
||||
setShowModal(true)
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
// setShowAccountSettingModal would prevent navigation
|
||||
if (path.startsWith('/education-apply'))
|
||||
setShowAccountSettingModal(null)
|
||||
}, [path, setShowAccountSettingModal])
|
||||
return (
|
||||
<div className='relative rounded-2xl border-[0.5px] border-effects-highlight-lightmode-off bg-background-section-burn'>
|
||||
<div className='p-6 pb-2'>
|
||||
@ -106,10 +96,9 @@ const PlanComp: FC<Props> = ({
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-1'>
|
||||
{enableEducationPlan && (!isEducationAccount || isAboutToExpire) && (
|
||||
<Button variant='ghost' onClick={handleVerify} disabled={isPending} >
|
||||
<Button variant='ghost' onClick={handleVerify}>
|
||||
<RiGraduationCapLine className='mr-1 h-4 w-4' />
|
||||
{t('education.toVerified')}
|
||||
{isPending && <Loading className='ml-1 animate-spin-slow' />}
|
||||
</Button>
|
||||
)}
|
||||
{(plan.type as any) !== SelfHostedPlan.enterprise && (
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
import type { CrawlResultItem } from '@/models/datasets'
|
||||
|
||||
const result: CrawlResultItem[] = [
|
||||
{
|
||||
title: 'Start the frontend Docker container separately',
|
||||
content: 'Markdown 1',
|
||||
description: 'Description 1',
|
||||
source_url: 'https://example.com/1',
|
||||
},
|
||||
{
|
||||
title: 'Advanced Tool Integration',
|
||||
content: 'Markdown 2',
|
||||
description: 'Description 2',
|
||||
source_url: 'https://example.com/2',
|
||||
},
|
||||
{
|
||||
title: 'Local Source Code Start | English | Dify',
|
||||
content: 'Markdown 3',
|
||||
description: 'Description 3',
|
||||
source_url: 'https://example.com/3',
|
||||
},
|
||||
]
|
||||
|
||||
export default result
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user