Compare commits

..

69 Commits

Author SHA1 Message Date
4e037d14d1 Merge branch 'main' into feat/memory-orchestration-fed 2025-12-02 10:59:54 +08:00
08ca4e3e8b Merge branch 'main' into feat/memory-orchestration-fed 2025-11-28 12:01:11 +08:00
0998fab282 Merge branch 'main' into feat/memory-orchestration-fed 2025-11-18 10:05:39 +08:00
eb536956ef Merge branch 'main' into feat/memory-orchestration-fed 2025-11-17 10:10:02 +08:00
338e10424c Merge branch 'main' into feat/memory-orchestration-fed 2025-11-14 10:00:01 +08:00
c2ce2be102 merge main 2025-11-13 11:23:51 +08:00
b7311bf1d3 Merge branch 'main' into feat/memory-orchestration-fed 2025-11-10 10:47:28 +08:00
4050ed8c8e fix 2025-11-10 10:46:58 +08:00
2f0076166a Merge branch 'main' into feat/memory-orchestration-fed 2025-11-06 15:50:38 +08:00
896a29b836 fix 2025-11-06 15:49:50 +08:00
7cef0fff89 fix: 2025-11-05 18:28:17 +08:00
9d310fed92 fix: memory variable item 2025-11-04 17:53:51 +08:00
2944f831e2 Merge branch 'main' into feat/memory-orchestration-fed 2025-11-04 15:05:56 +08:00
10f5270320 feat: memory 2025-10-28 18:27:59 +08:00
38d27100ac Merge branch 'main' into feat/memory-orchestration-fed 2025-10-28 14:04:06 +08:00
9efbfdda55 Merge branch 'main' into feat/memory-orchestration-fed 2025-10-24 10:40:30 +08:00
7e2d16a518 feat: memory 2025-10-24 10:39:27 +08:00
6b2d460023 feat: memory 2025-10-22 16:28:08 +08:00
1cf7007fb4 fix: form validator 2025-10-22 11:38:46 +08:00
828b9c6a93 Merge branch 'main' into feat/memory-orchestration-fed 2025-10-21 13:27:10 +08:00
c8188274a2 Merge branch 'main' into feat/memory-orchestration-fed 2025-10-21 13:01:37 +08:00
d1d419e2b0 merge main 2025-10-20 14:11:20 +08:00
e90086c2d2 add memory variable 2025-10-16 18:30:15 +08:00
1c3ff179f8 use setMemoryVariables 2025-10-15 16:44:15 +08:00
8a348615bf Merge branch 'main' into feat/memory-orchestration-fed 2025-10-15 16:20:00 +08:00
2a27d81553 add memory variable 2025-10-15 16:15:16 +08:00
6b6ab5e034 merge main 2025-10-14 10:26:12 +08:00
50a7d93ddc memory in embedded chatbot 2025-10-12 13:22:04 +08:00
61e4bc6b17 memory in web app 2025-10-12 13:13:33 +08:00
0e545d7be5 Merge branch 'main' into feat/memory-orchestration-fed 2025-10-11 14:08:09 +08:00
eddc39cd0a fix: error import 2025-09-24 10:45:11 +08:00
efcaa2bbbd support create memory var in memory popup 2025-09-23 17:42:53 +08:00
05c05bb6d0 empty state 2025-09-23 16:24:15 +08:00
57624979e4 select memory var in popup 2025-09-23 16:07:46 +08:00
7e9375ce7e fix 2025-09-23 10:45:05 +08:00
0b1445aed5 memory popup 2025-09-23 10:20:15 +08:00
f6623423dd memory var sort 2025-09-23 10:20:09 +08:00
77b7bf9d47 memory 2025-09-22 18:00:05 +08:00
b0dd7d303d merge main 2025-09-22 14:45:17 +08:00
f8097b20c8 fix: memory type 2025-09-22 14:43:04 +08:00
257e43e2c2 add memory icon in editor block 2025-09-15 18:40:08 +08:00
55f146f527 node selector 2025-09-15 16:30:25 +08:00
2cd9d1066f node selector of base field 2025-09-15 15:39:46 +08:00
612112c919 add memory variable in llm node 2025-09-12 16:27:20 +08:00
df502e0d79 update memory by SSE in preview 2025-09-12 14:35:07 +08:00
27d6fee1ed merge main 2025-09-05 14:22:49 +08:00
35c116906e fix node memory 2025-09-02 17:30:00 +08:00
e9685a38cb merge main 2025-09-02 16:32:25 +08:00
a7156244f7 llm node 2025-08-28 10:53:29 +08:00
01f0ee339e form 2025-08-26 18:29:58 +08:00
158da1ce6e merge main 2025-08-26 16:11:07 +08:00
761ad336e9 form 2025-08-26 16:07:56 +08:00
239e15eac6 Merge branch 'main' into feat/memory-orchestration-fed 2025-08-25 14:05:56 +08:00
2acee44071 Merge branch 'main' into feat/memory-orchestration-fed 2025-08-21 14:28:16 +08:00
944b2ff22d form 2025-08-14 15:58:50 +08:00
a5dfc09e90 Merge branch 'main' into feat/memory-orchestration-fed 2025-08-14 15:04:56 +08:00
8266dc1dcc form 2025-08-13 10:12:17 +08:00
edeea0a8b9 Merge branch 'main' into feat/memory-orchestration-fed 2025-08-12 13:44:59 +08:00
dfc17f3b08 Merge branch 'main' into feat/memory-orchestration-fed 2025-08-11 10:38:43 +08:00
bca0b7c087 form 2025-08-08 17:21:52 +08:00
985becbc41 Merge branch 'main' into feat/memory-orchestration-fed 2025-08-08 13:45:47 +08:00
c90ab89323 embedded chat memory UI 2025-07-31 16:54:39 +08:00
8547d4b1ff memory panel in mobile 2025-07-31 16:18:19 +08:00
fbcfebbba1 memory edit modal 2025-07-31 15:14:37 +08:00
f827f8dc63 memory card operation 2025-07-31 14:32:35 +08:00
fef9907af3 memory card 2025-07-31 12:07:48 +08:00
e3b7f8afdd memory list 2025-07-30 17:00:04 +08:00
dceac39078 memory panel 2025-07-30 15:58:57 +08:00
58fd2f7a2f add chat memory action button 2025-07-30 14:42:46 +08:00
234 changed files with 10326 additions and 10802 deletions

View File

@ -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 }}

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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,
)

View File

@ -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]

View File

@ -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.")

View File

@ -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

View File

@ -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:
"""

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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):

View File

@ -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 ###

View File

@ -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",

View File

@ -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.

View File

@ -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):

View File

@ -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):

View File

@ -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"

View File

@ -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=[

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
),
]

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
# ------------------------------

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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} />}
/>
)
}

View File

@ -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()

View File

@ -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')

View File

@ -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>

View File

@ -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)

View File

@ -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}&nbsp;{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}
/>
)}
</>

View File

@ -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}
/>
)
}

View File

@ -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>

View File

@ -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',

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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' },

View File

@ -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>

View File

@ -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}
/>

View File

@ -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)

View File

@ -1,2 +0,0 @@
export { default } from './AmplitudeProvider'
export { resetUser, setUserId, setUserProperties, trackEvent } from './utils'

View File

@ -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()
}

View File

@ -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'

View File

@ -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)

View File

@ -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}

View File

@ -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 && (

View File

@ -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>

View File

@ -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,
}
}

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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,
},
},
]

View File

@ -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}

View File

@ -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)

View File

@ -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>
)

View File

@ -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,
}
}

View File

@ -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>

View File

@ -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
}

View File

@ -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]

View File

@ -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' />
)
}
</>
)

View File

@ -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>
)
}

View File

@ -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)

View File

@ -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 : []

View File

@ -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

View File

@ -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

View File

@ -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"
}

View File

@ -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

View File

@ -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'

View File

@ -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)

View File

@ -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

View File

@ -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',

View File

@ -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,
)
}

View File

@ -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
}

View File

@ -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}
/>}

View File

@ -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)

View File

@ -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(

View File

@ -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) => {

View 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

View 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)
}

View File

@ -71,6 +71,7 @@ export type WorkflowVariableBlockType = {
getVarType?: GetVarType
showManageInputField?: boolean
onManageInputField?: () => void
isMemorySupported?: boolean
}
export type MenuTextMatch = {

View File

@ -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'

View File

@ -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)

View File

@ -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
}

View File

@ -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 && (

View File

@ -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