Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f320f9146 | ||
|
|
cd51d3323b | ||
|
|
004b3caa43 | ||
|
|
dbe10799e3 | ||
|
|
054ba88434 | ||
|
|
da82a11b26 | ||
|
|
fec607db81 | ||
|
|
397a92f2ee | ||
|
|
b91e226063 | ||
|
|
da5782df92 | ||
|
|
9af0da4450 | ||
|
|
d49ac1e4ac |
1
.gitignore
vendored
@@ -109,6 +109,7 @@ venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.conda/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
|
||||
@@ -8,13 +8,19 @@ EDITION=SELF_HOSTED
|
||||
SECRET_KEY=
|
||||
|
||||
# Console API base URL
|
||||
CONSOLE_URL=http://127.0.0.1:5001
|
||||
CONSOLE_API_URL=http://127.0.0.1:5001
|
||||
|
||||
# Console frontend web base URL
|
||||
CONSOLE_WEB_URL=http://127.0.0.1:3000
|
||||
|
||||
# Service API base URL
|
||||
API_URL=http://127.0.0.1:5001
|
||||
SERVICE_API_URL=http://127.0.0.1:5001
|
||||
|
||||
# Web APP base URL
|
||||
APP_URL=http://127.0.0.1:3000
|
||||
# Web APP API base URL
|
||||
APP_API_URL=http://127.0.0.1:5001
|
||||
|
||||
# Web APP frontend web base URL
|
||||
APP_WEB_URL=http://127.0.0.1:3000
|
||||
|
||||
# celery configuration
|
||||
CELERY_BROKER_URL=redis://:difyai123456@localhost:6379/1
|
||||
@@ -79,6 +85,11 @@ WEAVIATE_BATCH_SIZE=100
|
||||
QDRANT_URL=path:storage/qdrant
|
||||
QDRANT_API_KEY=your-qdrant-api-key
|
||||
|
||||
# Mail configuration, support: resend
|
||||
MAIL_TYPE=
|
||||
MAIL_DEFAULT_SEND_FROM=no-reply <no-reply@dify.ai>
|
||||
RESEND_API_KEY=
|
||||
|
||||
# Sentry configuration
|
||||
SENTRY_DSN=
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@ LABEL maintainer="takatost@gmail.com"
|
||||
ENV FLASK_APP app.py
|
||||
ENV EDITION SELF_HOSTED
|
||||
ENV DEPLOY_ENV PRODUCTION
|
||||
ENV CONSOLE_URL http://127.0.0.1:5001
|
||||
ENV API_URL http://127.0.0.1:5001
|
||||
ENV APP_URL http://127.0.0.1:5001
|
||||
ENV CONSOLE_API_URL http://127.0.0.1:5001
|
||||
ENV CONSOLE_WEB_URL http://127.0.0.1:3000
|
||||
ENV SERVICE_API_URL http://127.0.0.1:5001
|
||||
ENV APP_API_URL http://127.0.0.1:5001
|
||||
ENV APP_WEB_URL http://127.0.0.1:3000
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import flask_login
|
||||
from flask_cors import CORS
|
||||
|
||||
from extensions import ext_session, ext_celery, ext_sentry, ext_redis, ext_login, ext_migrate, \
|
||||
ext_database, ext_storage
|
||||
ext_database, ext_storage, ext_mail
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_login import login_manager
|
||||
|
||||
@@ -83,6 +83,7 @@ def initialize_extensions(app):
|
||||
ext_celery.init_app(app)
|
||||
ext_session.init_app(app)
|
||||
ext_login.init_app(app)
|
||||
ext_mail.init_app(app)
|
||||
ext_sentry.init_app(app)
|
||||
|
||||
|
||||
@@ -149,13 +150,17 @@ def register_blueprints(app):
|
||||
from controllers.web import bp as web_bp
|
||||
from controllers.console import bp as console_app_bp
|
||||
|
||||
CORS(service_api_bp,
|
||||
allow_headers=['Content-Type', 'Authorization', 'X-App-Code'],
|
||||
methods=['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'PATCH']
|
||||
)
|
||||
app.register_blueprint(service_api_bp)
|
||||
|
||||
CORS(web_bp,
|
||||
resources={
|
||||
r"/*": {"origins": app.config['WEB_API_CORS_ALLOW_ORIGINS']}},
|
||||
supports_credentials=True,
|
||||
allow_headers=['Content-Type', 'Authorization'],
|
||||
allow_headers=['Content-Type', 'Authorization', 'X-App-Code'],
|
||||
methods=['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'PATCH'],
|
||||
expose_headers=['X-Version', 'X-Env']
|
||||
)
|
||||
|
||||
@@ -28,9 +28,11 @@ DEFAULTS = {
|
||||
'SESSION_REDIS_USE_SSL': 'False',
|
||||
'OAUTH_REDIRECT_PATH': '/console/api/oauth/authorize',
|
||||
'OAUTH_REDIRECT_INDEX_PATH': '/',
|
||||
'CONSOLE_URL': 'https://cloud.dify.ai',
|
||||
'API_URL': 'https://api.dify.ai',
|
||||
'APP_URL': 'https://udify.app',
|
||||
'CONSOLE_WEB_URL': 'https://cloud.dify.ai',
|
||||
'CONSOLE_API_URL': 'https://cloud.dify.ai',
|
||||
'SERVICE_API_URL': 'https://api.dify.ai',
|
||||
'APP_WEB_URL': 'https://udify.app',
|
||||
'APP_API_URL': 'https://udify.app',
|
||||
'STORAGE_TYPE': 'local',
|
||||
'STORAGE_LOCAL_PATH': 'storage',
|
||||
'CHECK_UPDATE_URL': 'https://updates.dify.ai',
|
||||
@@ -76,10 +78,15 @@ class Config:
|
||||
|
||||
def __init__(self):
|
||||
# app settings
|
||||
self.CONSOLE_API_URL = get_env('CONSOLE_URL') if get_env('CONSOLE_URL') else get_env('CONSOLE_API_URL')
|
||||
self.CONSOLE_WEB_URL = get_env('CONSOLE_URL') if get_env('CONSOLE_URL') else get_env('CONSOLE_WEB_URL')
|
||||
self.SERVICE_API_URL = get_env('API_URL') if get_env('API_URL') else get_env('SERVICE_API_URL')
|
||||
self.APP_WEB_URL = get_env('APP_URL') if get_env('APP_URL') else get_env('APP_WEB_URL')
|
||||
self.APP_API_URL = get_env('APP_URL') if get_env('APP_URL') else get_env('APP_API_URL')
|
||||
self.CONSOLE_URL = get_env('CONSOLE_URL')
|
||||
self.API_URL = get_env('API_URL')
|
||||
self.APP_URL = get_env('APP_URL')
|
||||
self.CURRENT_VERSION = "0.3.7"
|
||||
self.CURRENT_VERSION = "0.3.8"
|
||||
self.COMMIT_SHA = get_env('COMMIT_SHA')
|
||||
self.EDITION = "SELF_HOSTED"
|
||||
self.DEPLOY_ENV = get_env('DEPLOY_ENV')
|
||||
@@ -147,10 +154,15 @@ class Config:
|
||||
|
||||
# cors settings
|
||||
self.CONSOLE_CORS_ALLOW_ORIGINS = get_cors_allow_origins(
|
||||
'CONSOLE_CORS_ALLOW_ORIGINS', self.CONSOLE_URL)
|
||||
'CONSOLE_CORS_ALLOW_ORIGINS', self.CONSOLE_WEB_URL)
|
||||
self.WEB_API_CORS_ALLOW_ORIGINS = get_cors_allow_origins(
|
||||
'WEB_API_CORS_ALLOW_ORIGINS', '*')
|
||||
|
||||
# mail settings
|
||||
self.MAIL_TYPE = get_env('MAIL_TYPE')
|
||||
self.MAIL_DEFAULT_SEND_FROM = get_env('MAIL_DEFAULT_SEND_FROM')
|
||||
self.RESEND_API_KEY = get_env('RESEND_API_KEY')
|
||||
|
||||
# sentry settings
|
||||
self.SENTRY_DSN = get_env('SENTRY_DSN')
|
||||
self.SENTRY_TRACES_SAMPLE_RATE = float(get_env('SENTRY_TRACES_SAMPLE_RATE'))
|
||||
|
||||
@@ -12,7 +12,7 @@ from . import setup, version, apikey, admin
|
||||
from .app import app, site, completion, model_config, statistic, conversation, message, generator, audio
|
||||
|
||||
# Import auth controllers
|
||||
from .auth import login, oauth, data_source_oauth
|
||||
from .auth import login, oauth, data_source_oauth, activate
|
||||
|
||||
# Import datasets controllers
|
||||
from .datasets import datasets, datasets_document, datasets_segments, file, hit_testing, data_source
|
||||
|
||||
75
api/controllers/console/auth/activate.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import base64
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
from flask_restful import Resource, reqparse
|
||||
|
||||
from controllers.console import api
|
||||
from controllers.console.error import AlreadyActivateError
|
||||
from extensions.ext_database import db
|
||||
from libs.helper import email, str_len, supported_language, timezone
|
||||
from libs.password import valid_password, hash_password
|
||||
from models.account import AccountStatus, Tenant
|
||||
from services.account_service import RegisterService
|
||||
|
||||
|
||||
class ActivateCheckApi(Resource):
|
||||
def get(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument('workspace_id', type=str, required=True, nullable=False, location='args')
|
||||
parser.add_argument('email', type=email, required=True, nullable=False, location='args')
|
||||
parser.add_argument('token', type=str, required=True, nullable=False, location='args')
|
||||
args = parser.parse_args()
|
||||
|
||||
account = RegisterService.get_account_if_token_valid(args['workspace_id'], args['email'], args['token'])
|
||||
|
||||
tenant = db.session.query(Tenant).filter(
|
||||
Tenant.id == args['workspace_id'],
|
||||
Tenant.status == 'normal'
|
||||
).first()
|
||||
|
||||
return {'is_valid': account is not None, 'workspace_name': tenant.name}
|
||||
|
||||
|
||||
class ActivateApi(Resource):
|
||||
def post(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument('workspace_id', type=str, required=True, nullable=False, location='json')
|
||||
parser.add_argument('email', type=email, required=True, nullable=False, location='json')
|
||||
parser.add_argument('token', type=str, required=True, nullable=False, location='json')
|
||||
parser.add_argument('name', type=str_len(30), required=True, nullable=False, location='json')
|
||||
parser.add_argument('password', type=valid_password, required=True, nullable=False, location='json')
|
||||
parser.add_argument('interface_language', type=supported_language, required=True, nullable=False,
|
||||
location='json')
|
||||
parser.add_argument('timezone', type=timezone, required=True, nullable=False, location='json')
|
||||
args = parser.parse_args()
|
||||
|
||||
account = RegisterService.get_account_if_token_valid(args['workspace_id'], args['email'], args['token'])
|
||||
if account is None:
|
||||
raise AlreadyActivateError()
|
||||
|
||||
RegisterService.revoke_token(args['workspace_id'], args['email'], args['token'])
|
||||
|
||||
account.name = args['name']
|
||||
|
||||
# generate password salt
|
||||
salt = secrets.token_bytes(16)
|
||||
base64_salt = base64.b64encode(salt).decode()
|
||||
|
||||
# encrypt password with salt
|
||||
password_hashed = hash_password(args['password'], salt)
|
||||
base64_password_hashed = base64.b64encode(password_hashed).decode()
|
||||
account.password = base64_password_hashed
|
||||
account.password_salt = base64_salt
|
||||
account.interface_language = args['interface_language']
|
||||
account.timezone = args['timezone']
|
||||
account.interface_theme = 'light'
|
||||
account.status = AccountStatus.ACTIVE.value
|
||||
account.initialized_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return {'result': 'success'}
|
||||
|
||||
|
||||
api.add_resource(ActivateCheckApi, '/activate/check')
|
||||
api.add_resource(ActivateApi, '/activate')
|
||||
@@ -20,7 +20,7 @@ def get_oauth_providers():
|
||||
client_secret=current_app.config.get(
|
||||
'NOTION_CLIENT_SECRET'),
|
||||
redirect_uri=current_app.config.get(
|
||||
'CONSOLE_URL') + '/console/api/oauth/data-source/callback/notion')
|
||||
'CONSOLE_API_URL') + '/console/api/oauth/data-source/callback/notion')
|
||||
|
||||
OAUTH_PROVIDERS = {
|
||||
'notion': notion_oauth
|
||||
@@ -42,7 +42,7 @@ class OAuthDataSource(Resource):
|
||||
if current_app.config.get('NOTION_INTEGRATION_TYPE') == 'internal':
|
||||
internal_secret = current_app.config.get('NOTION_INTERNAL_SECRET')
|
||||
oauth_provider.save_internal_access_token(internal_secret)
|
||||
return redirect(f'{current_app.config.get("CONSOLE_URL")}?oauth_data_source=success')
|
||||
return redirect(f'{current_app.config.get("CONSOLE_WEB_URL")}?oauth_data_source=success')
|
||||
else:
|
||||
auth_url = oauth_provider.get_authorization_url()
|
||||
return redirect(auth_url)
|
||||
@@ -66,12 +66,12 @@ class OAuthDataSourceCallback(Resource):
|
||||
f"An error occurred during the OAuthCallback process with {provider}: {e.response.text}")
|
||||
return {'error': 'OAuth data source process failed'}, 400
|
||||
|
||||
return redirect(f'{current_app.config.get("CONSOLE_URL")}?oauth_data_source=success')
|
||||
return redirect(f'{current_app.config.get("CONSOLE_WEB_URL")}?oauth_data_source=success')
|
||||
elif 'error' in request.args:
|
||||
error = request.args.get('error')
|
||||
return redirect(f'{current_app.config.get("CONSOLE_URL")}?oauth_data_source={error}')
|
||||
return redirect(f'{current_app.config.get("CONSOLE_WEB_URL")}?oauth_data_source={error}')
|
||||
else:
|
||||
return redirect(f'{current_app.config.get("CONSOLE_URL")}?oauth_data_source=access_denied')
|
||||
return redirect(f'{current_app.config.get("CONSOLE_WEB_URL")}?oauth_data_source=access_denied')
|
||||
|
||||
|
||||
class OAuthDataSourceSync(Resource):
|
||||
|
||||
@@ -20,13 +20,13 @@ def get_oauth_providers():
|
||||
client_secret=current_app.config.get(
|
||||
'GITHUB_CLIENT_SECRET'),
|
||||
redirect_uri=current_app.config.get(
|
||||
'CONSOLE_URL') + '/console/api/oauth/authorize/github')
|
||||
'CONSOLE_API_URL') + '/console/api/oauth/authorize/github')
|
||||
|
||||
google_oauth = GoogleOAuth(client_id=current_app.config.get('GOOGLE_CLIENT_ID'),
|
||||
client_secret=current_app.config.get(
|
||||
'GOOGLE_CLIENT_SECRET'),
|
||||
redirect_uri=current_app.config.get(
|
||||
'CONSOLE_URL') + '/console/api/oauth/authorize/google')
|
||||
'CONSOLE_API_URL') + '/console/api/oauth/authorize/google')
|
||||
|
||||
OAUTH_PROVIDERS = {
|
||||
'github': github_oauth,
|
||||
@@ -80,7 +80,7 @@ class OAuthCallback(Resource):
|
||||
flask_login.login_user(account, remember=True)
|
||||
AccountService.update_last_login(account, request)
|
||||
|
||||
return redirect(f'{current_app.config.get("CONSOLE_URL")}?oauth_login=success')
|
||||
return redirect(f'{current_app.config.get("CONSOLE_WEB_URL")}?oauth_login=success')
|
||||
|
||||
|
||||
def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) -> Optional[Account]:
|
||||
|
||||
@@ -18,3 +18,9 @@ class AccountNotLinkTenantError(BaseHTTPException):
|
||||
error_code = 'account_not_link_tenant'
|
||||
description = "Account not link tenant."
|
||||
code = 403
|
||||
|
||||
|
||||
class AlreadyActivateError(BaseHTTPException):
|
||||
error_code = 'already_activate'
|
||||
description = "Auth Token is invalid or account already activated, please check again."
|
||||
code = 403
|
||||
|
||||
@@ -6,22 +6,23 @@ from flask import current_app, request
|
||||
from flask_login import login_required, current_user
|
||||
from flask_restful import Resource, reqparse, fields, marshal_with
|
||||
|
||||
from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError
|
||||
from controllers.console import api
|
||||
from controllers.console.setup import setup_required
|
||||
from controllers.console.workspace.error import AccountAlreadyInitedError, InvalidInvitationCodeError, \
|
||||
RepeatPasswordNotMatchError
|
||||
RepeatPasswordNotMatchError, CurrentPasswordIncorrectError
|
||||
from controllers.console.wraps import account_initialization_required
|
||||
from libs.helper import TimestampField, supported_language, timezone
|
||||
from extensions.ext_database import db
|
||||
from models.account import InvitationCode, AccountIntegrate
|
||||
from services.account_service import AccountService
|
||||
|
||||
|
||||
account_fields = {
|
||||
'id': fields.String,
|
||||
'name': fields.String,
|
||||
'avatar': fields.String,
|
||||
'email': fields.String,
|
||||
'is_password_set': fields.Boolean,
|
||||
'interface_language': fields.String,
|
||||
'interface_theme': fields.String,
|
||||
'timezone': fields.String,
|
||||
@@ -194,8 +195,11 @@ class AccountPasswordApi(Resource):
|
||||
if args['new_password'] != args['repeat_new_password']:
|
||||
raise RepeatPasswordNotMatchError()
|
||||
|
||||
AccountService.update_account_password(
|
||||
current_user, args['password'], args['new_password'])
|
||||
try:
|
||||
AccountService.update_account_password(
|
||||
current_user, args['password'], args['new_password'])
|
||||
except ServiceCurrentPasswordIncorrectError:
|
||||
raise CurrentPasswordIncorrectError()
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ class RepeatPasswordNotMatchError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class CurrentPasswordIncorrectError(BaseHTTPException):
|
||||
error_code = 'current_password_incorrect'
|
||||
description = "Current password is incorrect."
|
||||
code = 400
|
||||
|
||||
|
||||
class ProviderRequestFailedError(BaseHTTPException):
|
||||
error_code = 'provider_request_failed'
|
||||
description = None
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
|
||||
from flask import current_app
|
||||
from flask_login import login_required, current_user
|
||||
from flask_restful import Resource, reqparse, marshal_with, abort, fields, marshal
|
||||
|
||||
@@ -60,7 +60,8 @@ class MemberInviteEmailApi(Resource):
|
||||
inviter = current_user
|
||||
|
||||
try:
|
||||
RegisterService.invite_new_member(inviter.current_tenant, invitee_email, role=invitee_role, inviter=inviter)
|
||||
token = RegisterService.invite_new_member(inviter.current_tenant, invitee_email, role=invitee_role,
|
||||
inviter=inviter)
|
||||
account = db.session.query(Account, TenantAccountJoin.role).join(
|
||||
TenantAccountJoin, Account.id == TenantAccountJoin.account_id
|
||||
).filter(Account.email == args['email']).first()
|
||||
@@ -78,7 +79,16 @@ class MemberInviteEmailApi(Resource):
|
||||
|
||||
# todo:413
|
||||
|
||||
return {'result': 'success', 'account': account}, 201
|
||||
return {
|
||||
'result': 'success',
|
||||
'account': account,
|
||||
'invite_url': '{}/activate?workspace_id={}&email={}&token={}'.format(
|
||||
current_app.config.get("CONSOLE_WEB_URL"),
|
||||
str(current_user.current_tenant_id),
|
||||
invitee_email,
|
||||
token
|
||||
)
|
||||
}, 201
|
||||
|
||||
|
||||
class MemberCancelInviteApi(Resource):
|
||||
@@ -88,7 +98,7 @@ class MemberCancelInviteApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def delete(self, member_id):
|
||||
member = Account.query.get(str(member_id))
|
||||
member = db.session.query(Account).filter(Account.id == str(member_id)).first()
|
||||
if not member:
|
||||
abort(404)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
from flask import request
|
||||
from flask_restful import fields, marshal_with, reqparse
|
||||
from flask_restful.inputs import int_range
|
||||
from werkzeug.exceptions import NotFound
|
||||
@@ -56,16 +57,14 @@ class ConversationDetailApi(AppApiResource):
|
||||
|
||||
conversation_id = str(c_id)
|
||||
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument('user', type=str, location='args')
|
||||
args = parser.parse_args()
|
||||
user = request.get_json().get('user')
|
||||
|
||||
if end_user is None and args['user'] is not None:
|
||||
end_user = create_or_update_end_user_for_user_id(app_model, args['user'])
|
||||
if end_user is None and user is not None:
|
||||
end_user = create_or_update_end_user_for_user_id(app_model, user)
|
||||
|
||||
try:
|
||||
ConversationService.delete(app_model, conversation_id, end_user)
|
||||
return {"result": "success"}, 204
|
||||
return {"result": "success"}
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
|
||||
@@ -95,3 +94,4 @@ class ConversationRenameApi(AppApiResource):
|
||||
api.add_resource(ConversationRenameApi, '/conversations/<uuid:c_id>/name', endpoint='conversation_name')
|
||||
api.add_resource(ConversationApi, '/conversations')
|
||||
api.add_resource(ConversationApi, '/conversations/<uuid:c_id>', endpoint='conversation')
|
||||
api.add_resource(ConversationDetailApi, '/conversations/<uuid:c_id>', endpoint='conversation_detail')
|
||||
|
||||
@@ -7,4 +7,4 @@ bp = Blueprint('web', __name__, url_prefix='/api')
|
||||
api = ExternalApi(bp)
|
||||
|
||||
|
||||
from . import completion, app, conversation, message, site, saved_message, audio
|
||||
from . import completion, app, conversation, message, site, saved_message, audio, passport
|
||||
|
||||
64
api/controllers/web/passport.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
import uuid
|
||||
from controllers.web import api
|
||||
from flask_restful import Resource
|
||||
from flask import request
|
||||
from werkzeug.exceptions import Unauthorized, NotFound
|
||||
from models.model import Site, EndUser, App
|
||||
from extensions.ext_database import db
|
||||
from libs.passport import PassportService
|
||||
|
||||
class PassportResource(Resource):
|
||||
"""Base resource for passport."""
|
||||
def get(self):
|
||||
app_id = request.headers.get('X-App-Code')
|
||||
if app_id is None:
|
||||
raise Unauthorized('X-App-Code header is missing.')
|
||||
|
||||
# get site from db and check if it is normal
|
||||
site = db.session.query(Site).filter(
|
||||
Site.code == app_id,
|
||||
Site.status == 'normal'
|
||||
).first()
|
||||
if not site:
|
||||
raise NotFound()
|
||||
# get app from db and check if it is normal and enable_site
|
||||
app_model = db.session.query(App).filter(App.id == site.app_id).first()
|
||||
if not app_model or app_model.status != 'normal' or not app_model.enable_site:
|
||||
raise NotFound()
|
||||
|
||||
end_user = EndUser(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
type='browser',
|
||||
is_anonymous=True,
|
||||
session_id=generate_session_id(),
|
||||
)
|
||||
db.session.add(end_user)
|
||||
db.session.commit()
|
||||
|
||||
payload = {
|
||||
"iss": site.app_id,
|
||||
'sub': 'Web API Passport',
|
||||
'app_id': site.app_id,
|
||||
'end_user_id': end_user.id,
|
||||
}
|
||||
|
||||
tk = PassportService().issue(payload)
|
||||
|
||||
return {
|
||||
'access_token': tk,
|
||||
}
|
||||
|
||||
api.add_resource(PassportResource, '/passport')
|
||||
|
||||
def generate_session_id():
|
||||
"""
|
||||
Generate a unique session ID.
|
||||
"""
|
||||
while True:
|
||||
session_id = str(uuid.uuid4())
|
||||
existing_count = db.session.query(EndUser) \
|
||||
.filter(EndUser.session_id == session_id).count()
|
||||
if existing_count == 0:
|
||||
return session_id
|
||||
@@ -1,110 +1,48 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
import uuid
|
||||
from functools import wraps
|
||||
|
||||
from flask import request, session
|
||||
from flask import request
|
||||
from flask_restful import Resource
|
||||
from werkzeug.exceptions import NotFound, Unauthorized
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.model import App, Site, EndUser
|
||||
from models.model import App, EndUser
|
||||
from libs.passport import PassportService
|
||||
|
||||
|
||||
def validate_token(view=None):
|
||||
def validate_jwt_token(view=None):
|
||||
def decorator(view):
|
||||
@wraps(view)
|
||||
def decorated(*args, **kwargs):
|
||||
site = validate_and_get_site()
|
||||
|
||||
app_model = db.session.query(App).filter(App.id == site.app_id).first()
|
||||
if not app_model:
|
||||
raise NotFound()
|
||||
|
||||
if app_model.status != 'normal':
|
||||
raise NotFound()
|
||||
|
||||
if not app_model.enable_site:
|
||||
raise NotFound()
|
||||
|
||||
end_user = create_or_update_end_user_for_session(app_model)
|
||||
app_model, end_user = decode_jwt_token()
|
||||
|
||||
return view(app_model, end_user, *args, **kwargs)
|
||||
return decorated
|
||||
|
||||
if view:
|
||||
return decorator(view)
|
||||
return decorator
|
||||
|
||||
|
||||
def validate_and_get_site():
|
||||
"""
|
||||
Validate and get API token.
|
||||
"""
|
||||
def decode_jwt_token():
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header is None:
|
||||
raise Unauthorized('Authorization header is missing.')
|
||||
|
||||
if ' ' not in auth_header:
|
||||
raise Unauthorized('Invalid Authorization header format. Expected \'Bearer <api-key>\' format.')
|
||||
|
||||
auth_scheme, auth_token = auth_header.split(None, 1)
|
||||
|
||||
auth_scheme, tk = auth_header.split(None, 1)
|
||||
auth_scheme = auth_scheme.lower()
|
||||
|
||||
if auth_scheme != 'bearer':
|
||||
raise Unauthorized('Invalid Authorization header format. Expected \'Bearer <api-key>\' format.')
|
||||
|
||||
site = db.session.query(Site).filter(
|
||||
Site.code == auth_token,
|
||||
Site.status == 'normal'
|
||||
).first()
|
||||
|
||||
if not site:
|
||||
decoded = PassportService().verify(tk)
|
||||
app_model = db.session.query(App).filter(App.id == decoded['app_id']).first()
|
||||
if not app_model:
|
||||
raise NotFound()
|
||||
end_user = db.session.query(EndUser).filter(EndUser.id == decoded['end_user_id']).first()
|
||||
if not end_user:
|
||||
raise NotFound()
|
||||
|
||||
return site
|
||||
|
||||
|
||||
def create_or_update_end_user_for_session(app_model):
|
||||
"""
|
||||
Create or update session terminal based on session ID.
|
||||
"""
|
||||
if 'session_id' not in session:
|
||||
session['session_id'] = generate_session_id()
|
||||
|
||||
session_id = session.get('session_id')
|
||||
end_user = db.session.query(EndUser) \
|
||||
.filter(
|
||||
EndUser.session_id == session_id,
|
||||
EndUser.type == 'browser'
|
||||
).first()
|
||||
|
||||
if end_user is None:
|
||||
end_user = EndUser(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
type='browser',
|
||||
is_anonymous=True,
|
||||
session_id=session_id
|
||||
)
|
||||
db.session.add(end_user)
|
||||
db.session.commit()
|
||||
|
||||
return end_user
|
||||
|
||||
|
||||
def generate_session_id():
|
||||
"""
|
||||
Generate a unique session ID.
|
||||
"""
|
||||
count = 1
|
||||
session_id = ''
|
||||
while count != 0:
|
||||
session_id = str(uuid.uuid4())
|
||||
count = db.session.query(EndUser) \
|
||||
.filter(EndUser.session_id == session_id).count()
|
||||
|
||||
return session_id
|
||||
|
||||
return app_model, end_user
|
||||
|
||||
class WebApiResource(Resource):
|
||||
method_decorators = [validate_token]
|
||||
method_decorators = [validate_jwt_token]
|
||||
|
||||
61
api/extensions/ext_mail.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from typing import Optional
|
||||
|
||||
import resend
|
||||
from flask import Flask
|
||||
|
||||
|
||||
class Mail:
|
||||
def __init__(self):
|
||||
self._client = None
|
||||
self._default_send_from = None
|
||||
|
||||
def is_inited(self) -> bool:
|
||||
return self._client is not None
|
||||
|
||||
def init_app(self, app: Flask):
|
||||
if app.config.get('MAIL_TYPE'):
|
||||
if app.config.get('MAIL_DEFAULT_SEND_FROM'):
|
||||
self._default_send_from = app.config.get('MAIL_DEFAULT_SEND_FROM')
|
||||
|
||||
if app.config.get('MAIL_TYPE') == 'resend':
|
||||
api_key = app.config.get('RESEND_API_KEY')
|
||||
if not api_key:
|
||||
raise ValueError('RESEND_API_KEY is not set')
|
||||
|
||||
resend.api_key = api_key
|
||||
self._client = resend.Emails
|
||||
else:
|
||||
raise ValueError('Unsupported mail type {}'.format(app.config.get('MAIL_TYPE')))
|
||||
|
||||
def send(self, to: str, subject: str, html: str, from_: Optional[str] = None):
|
||||
if not self._client:
|
||||
raise ValueError('Mail client is not initialized')
|
||||
|
||||
if not from_ and self._default_send_from:
|
||||
from_ = self._default_send_from
|
||||
|
||||
if not from_:
|
||||
raise ValueError('mail from is not set')
|
||||
|
||||
if not to:
|
||||
raise ValueError('mail to is not set')
|
||||
|
||||
if not subject:
|
||||
raise ValueError('mail subject is not set')
|
||||
|
||||
if not html:
|
||||
raise ValueError('mail html is not set')
|
||||
|
||||
self._client.send({
|
||||
"from": from_,
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"html": html
|
||||
})
|
||||
|
||||
|
||||
def init_app(app: Flask):
|
||||
mail.init_app(app)
|
||||
|
||||
|
||||
mail = Mail()
|
||||
20
api/libs/passport.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
import jwt
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
from flask import current_app
|
||||
class PassportService:
|
||||
def __init__(self):
|
||||
self.sk = current_app.config.get('SECRET_KEY')
|
||||
|
||||
def issue(self, payload):
|
||||
return jwt.encode(payload, self.sk, algorithm='HS256')
|
||||
|
||||
def verify(self, token):
|
||||
try:
|
||||
return jwt.decode(token, self.sk, algorithms=['HS256'])
|
||||
except jwt.exceptions.InvalidSignatureError:
|
||||
raise Unauthorized('Invalid token signature.')
|
||||
except jwt.exceptions.DecodeError:
|
||||
raise Unauthorized('Invalid token.')
|
||||
except jwt.exceptions.ExpiredSignatureError:
|
||||
raise Unauthorized('Token has expired.')
|
||||
@@ -38,6 +38,10 @@ class Account(UserMixin, db.Model):
|
||||
created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)'))
|
||||
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)'))
|
||||
|
||||
@property
|
||||
def is_password_set(self):
|
||||
return self.password is not None
|
||||
|
||||
@property
|
||||
def current_tenant(self):
|
||||
return self._current_tenant
|
||||
|
||||
@@ -56,7 +56,8 @@ class App(db.Model):
|
||||
|
||||
@property
|
||||
def api_base_url(self):
|
||||
return (current_app.config['API_URL'] if current_app.config['API_URL'] else request.host_url.rstrip('/')) + '/v1'
|
||||
return (current_app.config['SERVICE_API_URL'] if current_app.config['SERVICE_API_URL']
|
||||
else request.host_url.rstrip('/')) + '/v1'
|
||||
|
||||
@property
|
||||
def tenant(self):
|
||||
@@ -515,7 +516,7 @@ class Site(db.Model):
|
||||
|
||||
@property
|
||||
def app_base_url(self):
|
||||
return (current_app.config['APP_URL'] if current_app.config['APP_URL'] else request.host_url.rstrip('/'))
|
||||
return (current_app.config['APP_WEB_URL'] if current_app.config['APP_WEB_URL'] else request.host_url.rstrip('/'))
|
||||
|
||||
|
||||
class ApiToken(db.Model):
|
||||
|
||||
@@ -21,7 +21,7 @@ Authlib==1.2.0
|
||||
boto3~=1.26.123
|
||||
tenacity==8.2.2
|
||||
cachetools~=5.3.0
|
||||
weaviate-client~=3.16.2
|
||||
weaviate-client~=3.21.0
|
||||
qdrant_client~=1.1.6
|
||||
mailchimp-transactional~=1.0.50
|
||||
scikit-learn==1.2.2
|
||||
@@ -32,4 +32,6 @@ redis~=4.5.4
|
||||
openpyxl==3.1.2
|
||||
chardet~=5.1.0
|
||||
docx2txt==0.8
|
||||
pypdfium2==4.16.0
|
||||
pypdfium2==4.16.0
|
||||
resend~=0.5.1
|
||||
pyjwt~=2.6.0
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
import base64
|
||||
import logging
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from hashlib import sha256
|
||||
from typing import Optional
|
||||
|
||||
from flask import session
|
||||
from sqlalchemy import func
|
||||
|
||||
from events.tenant_event import tenant_was_created
|
||||
from extensions.ext_redis import redis_client
|
||||
from services.errors.account import AccountLoginError, CurrentPasswordIncorrectError, LinkAccountIntegrateError, \
|
||||
TenantNotFound, AccountNotLinkTenantError, InvalidActionError, CannotOperateSelfError, MemberNotInTenantError, \
|
||||
RoleAlreadyAssignedError, NoPermissionError, AccountRegisterError, AccountAlreadyInTenantError
|
||||
@@ -16,6 +19,7 @@ from libs.helper import get_remote_ip
|
||||
from libs.password import compare_password, hash_password
|
||||
from libs.rsa import generate_key_pair
|
||||
from models.account import *
|
||||
from tasks.mail_invite_member_task import send_invite_member_mail_task
|
||||
|
||||
|
||||
class AccountService:
|
||||
@@ -48,12 +52,18 @@ class AccountService:
|
||||
@staticmethod
|
||||
def update_account_password(account, password, new_password):
|
||||
"""update account password"""
|
||||
# todo: split validation and update
|
||||
if account.password and not compare_password(password, account.password, account.password_salt):
|
||||
raise CurrentPasswordIncorrectError("Current password is incorrect.")
|
||||
password_hashed = hash_password(new_password, account.password_salt)
|
||||
|
||||
# generate password salt
|
||||
salt = secrets.token_bytes(16)
|
||||
base64_salt = base64.b64encode(salt).decode()
|
||||
|
||||
# encrypt password with salt
|
||||
password_hashed = hash_password(new_password, salt)
|
||||
base64_password_hashed = base64.b64encode(password_hashed).decode()
|
||||
account.password = base64_password_hashed
|
||||
account.password_salt = base64_salt
|
||||
db.session.commit()
|
||||
return account
|
||||
|
||||
@@ -283,8 +293,6 @@ class TenantService:
|
||||
@staticmethod
|
||||
def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account) -> None:
|
||||
"""Remove member from tenant"""
|
||||
# todo: check permission
|
||||
|
||||
if operator.id == account.id and TenantService.check_member_permission(tenant, operator, account, 'remove'):
|
||||
raise CannotOperateSelfError("Cannot operate self.")
|
||||
|
||||
@@ -293,6 +301,12 @@ class TenantService:
|
||||
raise MemberNotInTenantError("Member not in tenant.")
|
||||
|
||||
db.session.delete(ta)
|
||||
|
||||
account.initialized_at = None
|
||||
account.status = AccountStatus.PENDING.value
|
||||
account.password = None
|
||||
account.password_salt = None
|
||||
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
@@ -332,8 +346,8 @@ class TenantService:
|
||||
|
||||
class RegisterService:
|
||||
|
||||
@staticmethod
|
||||
def register(email, name, password: str = None, open_id: str = None, provider: str = None) -> Account:
|
||||
@classmethod
|
||||
def register(cls, email, name, password: str = None, open_id: str = None, provider: str = None) -> Account:
|
||||
db.session.begin_nested()
|
||||
"""Register account"""
|
||||
try:
|
||||
@@ -359,9 +373,9 @@ class RegisterService:
|
||||
|
||||
return account
|
||||
|
||||
@staticmethod
|
||||
def invite_new_member(tenant: Tenant, email: str, role: str = 'normal',
|
||||
inviter: Account = None) -> TenantAccountJoin:
|
||||
@classmethod
|
||||
def invite_new_member(cls, tenant: Tenant, email: str, role: str = 'normal',
|
||||
inviter: Account = None) -> str:
|
||||
"""Invite new member"""
|
||||
account = Account.query.filter_by(email=email).first()
|
||||
|
||||
@@ -380,5 +394,71 @@ class RegisterService:
|
||||
if ta:
|
||||
raise AccountAlreadyInTenantError("Account already in tenant.")
|
||||
|
||||
ta = TenantService.create_tenant_member(tenant, account, role)
|
||||
return ta
|
||||
TenantService.create_tenant_member(tenant, account, role)
|
||||
|
||||
token = cls.generate_invite_token(tenant, account)
|
||||
|
||||
# send email
|
||||
send_invite_member_mail_task.delay(
|
||||
to=email,
|
||||
token=cls.generate_invite_token(tenant, account),
|
||||
inviter_name=inviter.name if inviter else 'Dify',
|
||||
workspace_id=tenant.id,
|
||||
workspace_name=tenant.name,
|
||||
)
|
||||
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
def generate_invite_token(cls, tenant: Tenant, account: Account) -> str:
|
||||
token = str(uuid.uuid4())
|
||||
email_hash = sha256(account.email.encode()).hexdigest()
|
||||
cache_key = 'member_invite_token:{}, {}:{}'.format(str(tenant.id), email_hash, token)
|
||||
redis_client.setex(cache_key, 3600, str(account.id))
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
def revoke_token(cls, workspace_id: str, email: str, token: str):
|
||||
email_hash = sha256(email.encode()).hexdigest()
|
||||
cache_key = 'member_invite_token:{}, {}:{}'.format(workspace_id, email_hash, token)
|
||||
redis_client.delete(cache_key)
|
||||
|
||||
@classmethod
|
||||
def get_account_if_token_valid(cls, workspace_id: str, email: str, token: str) -> Optional[Account]:
|
||||
tenant = db.session.query(Tenant).filter(
|
||||
Tenant.id == workspace_id,
|
||||
Tenant.status == 'normal'
|
||||
).first()
|
||||
|
||||
if not tenant:
|
||||
return None
|
||||
|
||||
tenant_account = db.session.query(Account, TenantAccountJoin.role).join(
|
||||
TenantAccountJoin, Account.id == TenantAccountJoin.account_id
|
||||
).filter(Account.email == email, TenantAccountJoin.tenant_id == tenant.id).first()
|
||||
|
||||
if not tenant_account:
|
||||
return None
|
||||
|
||||
account_id = cls._get_account_id_by_invite_token(workspace_id, email, token)
|
||||
if not account_id:
|
||||
return None
|
||||
|
||||
account = tenant_account[0]
|
||||
if not account:
|
||||
return None
|
||||
|
||||
if account_id != str(account.id):
|
||||
return None
|
||||
|
||||
return account
|
||||
|
||||
@classmethod
|
||||
def _get_account_id_by_invite_token(cls, workspace_id: str, email: str, token: str) -> Optional[str]:
|
||||
email_hash = sha256(email.encode()).hexdigest()
|
||||
cache_key = 'member_invite_token:{}, {}:{}'.format(workspace_id, email_hash, token)
|
||||
account_id = redis_client.get(cache_key)
|
||||
if not account_id:
|
||||
return None
|
||||
|
||||
return account_id.decode('utf-8')
|
||||
|
||||
@@ -6,7 +6,8 @@ from services.errors.audio import NoAudioUploadedServiceError, AudioTooLargeServ
|
||||
from core.llm.whisper import Whisper
|
||||
from models.provider import ProviderName
|
||||
|
||||
FILE_SIZE_LIMIT = 1 * 1024 * 1024
|
||||
FILE_SIZE = 15
|
||||
FILE_SIZE_LIMIT = FILE_SIZE * 1024 * 1024
|
||||
ALLOWED_EXTENSIONS = ['mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'wav', 'webm']
|
||||
|
||||
class AudioService:
|
||||
@@ -23,17 +24,17 @@ class AudioService:
|
||||
file_size = len(file_content)
|
||||
|
||||
if file_size > FILE_SIZE_LIMIT:
|
||||
message = f"({file_size} > {FILE_SIZE_LIMIT})"
|
||||
message = f"Audio size larger than {FILE_SIZE} mb"
|
||||
raise AudioTooLargeServiceError(message)
|
||||
|
||||
provider_name = LLMBuilder.get_default_provider(tenant_id)
|
||||
if provider_name != ProviderName.OPENAI.value:
|
||||
raise ProviderNotSupportSpeechToTextServiceError('haha')
|
||||
raise ProviderNotSupportSpeechToTextServiceError()
|
||||
|
||||
provider_service = LLMProviderService(tenant_id, provider_name)
|
||||
|
||||
buffer = io.BytesIO(file_content)
|
||||
buffer.name = 'temp.wav'
|
||||
buffer.name = 'temp.mp3'
|
||||
|
||||
return Whisper(provider_service.provider).transcribe(buffer)
|
||||
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
from services.errors.base import BaseServiceError
|
||||
|
||||
class NoAudioUploadedServiceError(BaseServiceError):
|
||||
error_code = 'no_audio_uploaded'
|
||||
description = "Please upload your audio."
|
||||
code = 400
|
||||
class NoAudioUploadedServiceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AudioTooLargeServiceError(BaseServiceError):
|
||||
error_code = 'audio_too_large'
|
||||
description = "Audio size exceeded. {message}"
|
||||
code = 413
|
||||
class AudioTooLargeServiceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UnsupportedAudioTypeServiceError(BaseServiceError):
|
||||
error_code = 'unsupported_audio_type'
|
||||
description = "Audio type not allowed."
|
||||
code = 415
|
||||
class UnsupportedAudioTypeServiceError(Exception):
|
||||
pass
|
||||
|
||||
class ProviderNotSupportSpeechToTextServiceError(BaseServiceError):
|
||||
error_code = 'provider_not_support_speech_to_text'
|
||||
description = "Provider not support speech to text. {message}"
|
||||
code = 400
|
||||
class ProviderNotSupportSpeechToTextServiceError(Exception):
|
||||
pass
|
||||
52
api/tasks/mail_invite_member_task.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
import click
|
||||
from celery import shared_task
|
||||
from flask import current_app
|
||||
|
||||
from extensions.ext_mail import mail
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_invite_member_mail_task(to: str, token: str, inviter_name: str, workspace_id: str, workspace_name: str):
|
||||
"""
|
||||
Async Send invite member mail
|
||||
:param to
|
||||
:param token
|
||||
:param inviter_name
|
||||
:param workspace_id
|
||||
:param workspace_name
|
||||
|
||||
Usage: send_invite_member_mail_task.delay(to, token, inviter_name, workspace_id, workspace_name)
|
||||
"""
|
||||
if not mail.is_inited():
|
||||
return
|
||||
|
||||
logging.info(click.style('Start send invite member mail to {} in workspace {}'.format(to, workspace_name),
|
||||
fg='green'))
|
||||
start_at = time.perf_counter()
|
||||
|
||||
try:
|
||||
mail.send(
|
||||
to=to,
|
||||
subject="{} invited you to join {}".format(inviter_name, workspace_name),
|
||||
html="""<p>Hi there,</p>
|
||||
<p>{inviter_name} invited you to join {workspace_name}.</p>
|
||||
<p>Click <a href="{url}">here</a> to join.</p>
|
||||
<p>Thanks,</p>
|
||||
<p>Dify Team</p>""".format(inviter_name=inviter_name, workspace_name=workspace_name,
|
||||
url='{}/activate?workspace_id={}&email={}&token={}'.format(
|
||||
current_app.config.get("CONSOLE_WEB_URL"),
|
||||
workspace_id,
|
||||
to,
|
||||
token)
|
||||
)
|
||||
)
|
||||
|
||||
end_at = time.perf_counter()
|
||||
logging.info(
|
||||
click.style('Send invite member mail to {} succeeded: latency: {}'.format(to, end_at - start_at),
|
||||
fg='green'))
|
||||
except Exception:
|
||||
logging.exception("Send invite member mail to {} failed".format(to))
|
||||
@@ -2,7 +2,7 @@ version: '3.1'
|
||||
services:
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:0.3.7
|
||||
image: langgenius/dify-api:0.3.8
|
||||
restart: always
|
||||
environment:
|
||||
# Startup mode, 'api' starts the API server.
|
||||
@@ -11,18 +11,26 @@ services:
|
||||
LOG_LEVEL: INFO
|
||||
# A secret key that is used for securely signing the session cookie and encrypting sensitive information on the database. You can generate a strong key using `openssl rand -base64 42`.
|
||||
SECRET_KEY: sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U
|
||||
# The base URL of console application, refers to the Console base URL of WEB service if console domain is
|
||||
# The base URL of console application web frontend, refers to the Console base URL of WEB service if console domain is
|
||||
# different from api or web app domain.
|
||||
# example: http://cloud.dify.ai
|
||||
CONSOLE_URL: ''
|
||||
CONSOLE_WEB_URL: ''
|
||||
# The base URL of console application api server, refers to the Console base URL of WEB service if console domain is
|
||||
# different from api or web app domain.
|
||||
# example: http://cloud.dify.ai
|
||||
CONSOLE_API_URL: ''
|
||||
# The URL for Service API endpoints,refers to the base URL of the current API service if api domain is
|
||||
# different from console domain.
|
||||
# example: http://api.dify.ai
|
||||
API_URL: ''
|
||||
# The URL for Web APP, refers to the Web App base URL of WEB service if web app domain is different from
|
||||
SERVICE_API_URL: ''
|
||||
# The URL for Web APP api server, refers to the Web App base URL of WEB service if web app domain is different from
|
||||
# console or api domain.
|
||||
# example: http://udify.app
|
||||
APP_URL: ''
|
||||
APP_API_URL: ''
|
||||
# The URL for Web APP frontend, refers to the Web App base URL of WEB service if web app domain is different from
|
||||
# console or api domain.
|
||||
# example: http://udify.app
|
||||
APP_WEB_URL: ''
|
||||
# When enabled, migrations will be executed prior to application startup and the application will start after the migrations have completed.
|
||||
MIGRATION_ENABLED: 'true'
|
||||
# The configurations of postgres database connection.
|
||||
@@ -93,6 +101,12 @@ services:
|
||||
QDRANT_URL: 'https://your-qdrant-cluster-url.qdrant.tech/'
|
||||
# The Qdrant API key.
|
||||
QDRANT_API_KEY: 'ak-difyai'
|
||||
# Mail configuration, support: resend
|
||||
MAIL_TYPE: ''
|
||||
# default send from email address, if not specified
|
||||
MAIL_DEFAULT_SEND_FROM: 'YOUR EMAIL FROM (eg: no-reply <no-reply@dify.ai>)'
|
||||
# the api-key for resend (https://resend.com)
|
||||
RESEND_API_KEY: ''
|
||||
# The DSN for Sentry error reporting. If not set, Sentry error reporting will be disabled.
|
||||
SENTRY_DSN: ''
|
||||
# The sample rate for Sentry events. Default: `1.0`
|
||||
@@ -110,7 +124,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing the queue.
|
||||
worker:
|
||||
image: langgenius/dify-api:0.3.7
|
||||
image: langgenius/dify-api:0.3.8
|
||||
restart: always
|
||||
environment:
|
||||
# Startup mode, 'worker' starts the Celery worker for processing the queue.
|
||||
@@ -146,6 +160,12 @@ services:
|
||||
VECTOR_STORE: weaviate
|
||||
WEAVIATE_ENDPOINT: http://weaviate:8080
|
||||
WEAVIATE_API_KEY: WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih
|
||||
# Mail configuration, support: resend
|
||||
MAIL_TYPE: ''
|
||||
# default send from email address, if not specified
|
||||
MAIL_DEFAULT_SEND_FROM: 'YOUR EMAIL FROM (eg: no-reply <no-reply@dify.ai>)'
|
||||
# the api-key for resend (https://resend.com)
|
||||
RESEND_API_KEY: ''
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
@@ -156,18 +176,18 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:0.3.7
|
||||
image: langgenius/dify-web:0.3.8
|
||||
restart: always
|
||||
environment:
|
||||
EDITION: SELF_HOSTED
|
||||
# The base URL of console application, refers to the Console base URL of WEB service if console domain is
|
||||
# The base URL of console application api server, refers to the Console base URL of WEB service if console domain is
|
||||
# different from api or web app domain.
|
||||
# example: http://cloud.dify.ai
|
||||
CONSOLE_URL: ''
|
||||
# The URL for Web APP, refers to the Web App base URL of WEB service if web app domain is different from
|
||||
CONSOLE_API_URL: ''
|
||||
# The URL for Web APP api server, refers to the Web App base URL of WEB service if web app domain is different from
|
||||
# console or api domain.
|
||||
# example: http://udify.app
|
||||
APP_URL: ''
|
||||
APP_API_URL: ''
|
||||
# The DSN for Sentry error reporting. If not set, Sentry error reporting will be disabled.
|
||||
SENTRY_DSN: ''
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ LABEL maintainer="takatost@gmail.com"
|
||||
|
||||
ENV EDITION SELF_HOSTED
|
||||
ENV DEPLOY_ENV PRODUCTION
|
||||
ENV CONSOLE_URL http://127.0.0.1:5001
|
||||
ENV APP_URL http://127.0.0.1:5001
|
||||
ENV CONSOLE_API_URL http://127.0.0.1:5001
|
||||
ENV APP_API_URL http://127.0.0.1:5001
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
13
web/app/(shareLayout)/chatbot/[token]/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
import type { IMainProps } from '@/app/components/share/chat'
|
||||
import Main from '@/app/components/share/chatbot'
|
||||
|
||||
const Chatbot: FC<IMainProps> = () => {
|
||||
return (
|
||||
<Main />
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Chatbot)
|
||||
233
web/app/activate/activateForm.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import cn from 'classnames'
|
||||
import Link from 'next/link'
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/solid'
|
||||
import style from './style.module.css'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import { timezones } from '@/utils/timezone'
|
||||
import { languageMaps, languages } from '@/utils/language'
|
||||
import { activateMember, invitationCheck } from '@/service/common'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
|
||||
const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
|
||||
|
||||
const ActivateForm = () => {
|
||||
const { t } = useTranslation()
|
||||
const searchParams = useSearchParams()
|
||||
const workspaceID = searchParams.get('workspace_id')
|
||||
const email = searchParams.get('email')
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const checkParams = {
|
||||
url: '/activate/check',
|
||||
params: {
|
||||
workspace_id: workspaceID,
|
||||
email,
|
||||
token,
|
||||
},
|
||||
}
|
||||
const { data: checkRes, mutate: recheck } = useSWR(checkParams, invitationCheck, {
|
||||
revalidateOnFocus: false,
|
||||
})
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [timezone, setTimezone] = useState('Asia/Shanghai')
|
||||
const [language, setLanguage] = useState('en-US')
|
||||
const [showSuccess, setShowSuccess] = useState(false)
|
||||
|
||||
const showErrorMessage = (message: string) => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message,
|
||||
})
|
||||
}
|
||||
const valid = () => {
|
||||
if (!name.trim()) {
|
||||
showErrorMessage(t('login.error.nameEmpty'))
|
||||
return false
|
||||
}
|
||||
if (!password.trim()) {
|
||||
showErrorMessage(t('login.error.passwordEmpty'))
|
||||
return false
|
||||
}
|
||||
if (!validPassword.test(password))
|
||||
showErrorMessage(t('login.error.passwordInvalid'))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleActivate = async () => {
|
||||
if (!valid())
|
||||
return
|
||||
try {
|
||||
await activateMember({
|
||||
url: '/activate',
|
||||
body: {
|
||||
workspace_id: workspaceID,
|
||||
email,
|
||||
token,
|
||||
name,
|
||||
password,
|
||||
interface_language: language,
|
||||
timezone,
|
||||
},
|
||||
})
|
||||
setShowSuccess(true)
|
||||
}
|
||||
catch {
|
||||
recheck()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={
|
||||
cn(
|
||||
'flex flex-col items-center w-full grow items-center justify-center',
|
||||
'px-6',
|
||||
'md:px-[108px]',
|
||||
)
|
||||
}>
|
||||
{!checkRes && <Loading/>}
|
||||
{checkRes && !checkRes.is_valid && (
|
||||
<div className="flex flex-col md:w-[400px]">
|
||||
<div className="w-full mx-auto">
|
||||
<div className="mb-3 flex justify-center items-center w-20 h-20 p-5 rounded-[20px] border border-gray-100 shadow-lg text-[40px] font-bold">🤷♂️</div>
|
||||
<h2 className="text-[32px] font-bold text-gray-900">{t('login.invalid')}</h2>
|
||||
</div>
|
||||
<div className="w-full mx-auto mt-6">
|
||||
<Button type='primary' className='w-full !fone-medium !text-sm'>
|
||||
<a href="https://dify.ai">{t('login.explore')}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{checkRes && checkRes.is_valid && !showSuccess && (
|
||||
<div className='flex flex-col md:w-[400px]'>
|
||||
<div className="w-full mx-auto">
|
||||
<div className={`mb-3 flex justify-center items-center w-20 h-20 p-5 rounded-[20px] border border-gray-100 shadow-lg text-[40px] font-bold ${style.logo}`}>
|
||||
</div>
|
||||
<h2 className="text-[32px] font-bold text-gray-900">
|
||||
{`${t('login.join')} ${checkRes.workspace_name}`}
|
||||
</h2>
|
||||
<p className='mt-1 text-sm text-gray-600 '>
|
||||
{`${t('login.joinTipStart')} ${checkRes.workspace_name} ${t('login.joinTipEnd')}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full mx-auto mt-6">
|
||||
<div className="bg-white">
|
||||
{/* username */}
|
||||
<div className='mb-5'>
|
||||
<label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
{t('login.name')}
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={t('login.namePlaceholder') || ''}
|
||||
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* password */}
|
||||
<div className='mb-5'>
|
||||
<label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
{t('login.password')}
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<input
|
||||
id="password"
|
||||
type='password'
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder={t('login.passwordPlaceholder') || ''}
|
||||
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-1 text-xs text-gray-500'>{t('login.error.passwordInvalid')}</div>
|
||||
</div>
|
||||
{/* language */}
|
||||
<div className='mb-5'>
|
||||
<label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
{t('login.interfaceLanguage')}
|
||||
</label>
|
||||
<div className="relative mt-1 rounded-md shadow-sm">
|
||||
<SimpleSelect
|
||||
defaultValue={languageMaps.en}
|
||||
items={languages}
|
||||
onSelect={(item) => {
|
||||
setLanguage(item.value as string)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* timezone */}
|
||||
<div className='mb-4'>
|
||||
<label htmlFor="timezone" className="block text-sm font-medium text-gray-700">
|
||||
{t('login.timezone')}
|
||||
</label>
|
||||
<div className="relative mt-1 rounded-md shadow-sm">
|
||||
<SimpleSelect
|
||||
defaultValue={timezone}
|
||||
items={timezones}
|
||||
onSelect={(item) => {
|
||||
setTimezone(item.value as string)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type='primary'
|
||||
className='w-full !fone-medium !text-sm'
|
||||
onClick={handleActivate}
|
||||
>
|
||||
{`${t('login.join')} ${checkRes.workspace_name}`}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="block w-hull mt-2 text-xs text-gray-600">
|
||||
{t('login.license.tip')}
|
||||
|
||||
<Link
|
||||
className='text-primary-600'
|
||||
target={'_blank'}
|
||||
href='https://docs.dify.ai/community/open-source'
|
||||
>{t('login.license.link')}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{checkRes && checkRes.is_valid && showSuccess && (
|
||||
<div className="flex flex-col md:w-[400px]">
|
||||
<div className="w-full mx-auto">
|
||||
<div className="mb-3 flex justify-center items-center w-20 h-20 p-5 rounded-[20px] border border-gray-100 shadow-lg text-[40px] font-bold">
|
||||
<CheckCircleIcon className='w-10 h-10 text-[#039855]' />
|
||||
</div>
|
||||
<h2 className="text-[32px] font-bold text-gray-900">
|
||||
{`${t('login.activatedTipStart')} ${checkRes.workspace_name} ${t('login.activatedTipEnd')}`}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="w-full mx-auto mt-6">
|
||||
<Button type='primary' className='w-full !fone-medium !text-sm'>
|
||||
<a href="/signin">{t('login.activated')}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActivateForm
|
||||
32
web/app/activate/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react'
|
||||
import cn from 'classnames'
|
||||
import Header from '../signin/_header'
|
||||
import style from '../signin/page.module.css'
|
||||
import ActivateForm from './activateForm'
|
||||
|
||||
const Activate = () => {
|
||||
return (
|
||||
<div className={cn(
|
||||
style.background,
|
||||
'flex w-full min-h-screen',
|
||||
'sm:p-4 lg:p-8',
|
||||
'gap-x-20',
|
||||
'justify-center lg:justify-start',
|
||||
)}>
|
||||
<div className={
|
||||
cn(
|
||||
'flex w-full flex-col bg-white shadow rounded-2xl shrink-0',
|
||||
'space-between',
|
||||
)
|
||||
}>
|
||||
<Header />
|
||||
<ActivateForm />
|
||||
<div className='px-8 py-6 text-sm font-normal text-gray-500'>
|
||||
© {new Date().getFullYear()} Dify, Inc. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Activate
|
||||
4
web/app/activate/style.module.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.logo {
|
||||
background: #fff center no-repeat url(./team-28x28.png);
|
||||
background-size: 56px;
|
||||
}
|
||||
BIN
web/app/activate/team-28x28.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
@@ -473,7 +473,7 @@ const Chat: FC<IChatProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const haneleKeyDown = (e: any) => {
|
||||
const handleKeyDown = (e: any) => {
|
||||
isUseInputMethod.current = e.nativeEvent.isComposing
|
||||
if (e.code === 'Enter' && !e.shiftKey) {
|
||||
setQuery(query.replace(/\n$/, ''))
|
||||
@@ -573,7 +573,7 @@ const Chat: FC<IChatProps> = ({
|
||||
value={query}
|
||||
onChange={handleContentChange}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={haneleKeyDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
minHeight={48}
|
||||
autoFocus
|
||||
controlFocus={controlFocus}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Cog8ToothIcon,
|
||||
@@ -11,6 +12,7 @@ import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SettingsModal from './settings'
|
||||
import ShareLink from './share-link'
|
||||
import EmbeddedModal from './embedded'
|
||||
import CustomizeModal from './customize'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import AppBasic, { randomString } from '@/app/components/app-sidebar/basic'
|
||||
@@ -18,6 +20,8 @@ import Button from '@/app/components/base/button'
|
||||
import Tag from '@/app/components/base/tag'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import './style.css'
|
||||
import { AppType } from '@/types/app'
|
||||
|
||||
export type IAppCardProps = {
|
||||
className?: string
|
||||
@@ -29,6 +33,10 @@ export type IAppCardProps = {
|
||||
onGenerateCode?: () => Promise<any>
|
||||
}
|
||||
|
||||
const EmbedIcon: FC<{ className?: string }> = ({ className = '' }) => {
|
||||
return <div className={`codeBrowserIcon ${className}`}></div>
|
||||
}
|
||||
|
||||
function AppCard({
|
||||
appInfo,
|
||||
cardType = 'app',
|
||||
@@ -42,6 +50,7 @@ function AppCard({
|
||||
const pathname = usePathname()
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [showEmbedded, setShowEmbedded] = useState(false)
|
||||
const [showCustomizeModal, setShowCustomizeModal] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -49,8 +58,9 @@ function AppCard({
|
||||
webapp: [
|
||||
{ opName: t('appOverview.overview.appInfo.preview'), opIcon: RocketLaunchIcon },
|
||||
{ opName: t('appOverview.overview.appInfo.share.entry'), opIcon: ShareIcon },
|
||||
appInfo.mode === AppType.chat ? { opName: t('appOverview.overview.appInfo.embedded.entry'), opIcon: EmbedIcon } : false,
|
||||
{ opName: t('appOverview.overview.appInfo.settings.entry'), opIcon: Cog8ToothIcon },
|
||||
],
|
||||
].filter(item => !!item),
|
||||
api: [{ opName: t('appOverview.overview.apiInfo.doc'), opIcon: DocumentTextIcon }],
|
||||
app: [],
|
||||
}
|
||||
@@ -80,6 +90,10 @@ function AppCard({
|
||||
return () => {
|
||||
setShowSettingsModal(true)
|
||||
}
|
||||
case t('appOverview.overview.appInfo.embedded.entry'):
|
||||
return () => {
|
||||
setShowEmbedded(true)
|
||||
}
|
||||
default:
|
||||
// jump to page develop
|
||||
return () => {
|
||||
@@ -139,20 +153,20 @@ function AppCard({
|
||||
key={op.opName}
|
||||
onClick={genClickFuncByName(op.opName)}
|
||||
disabled={
|
||||
[t('appOverview.overview.appInfo.preview'), t('appOverview.overview.appInfo.share.entry')].includes(op.opName) && !runningStatus
|
||||
[t('appOverview.overview.appInfo.preview'), t('appOverview.overview.appInfo.share.entry'), t('appOverview.overview.appInfo.embedded.entry')].includes(op.opName) && !runningStatus
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
content={t('appOverview.overview.appInfo.preUseReminder') ?? ''}
|
||||
selector={`op-btn-${randomString(16)}`}
|
||||
className={
|
||||
([t('appOverview.overview.appInfo.preview'), t('appOverview.overview.appInfo.share.entry')].includes(op.opName) && !runningStatus)
|
||||
([t('appOverview.overview.appInfo.preview'), t('appOverview.overview.appInfo.share.entry'), t('appOverview.overview.appInfo.embedded.entry')].includes(op.opName) && !runningStatus)
|
||||
? 'mt-[-8px]'
|
||||
: '!hidden'
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row items-center">
|
||||
<op.opIcon className="h-4 w-4 mr-1.5" />
|
||||
<op.opIcon className="h-4 w-4 mr-1.5 stroke-[1.8px]" />
|
||||
<span className="text-xs">{op.opName}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
@@ -193,6 +207,12 @@ function AppCard({
|
||||
onClose={() => setShowSettingsModal(false)}
|
||||
onSave={onSaveSiteConfig}
|
||||
/>
|
||||
<EmbeddedModal
|
||||
isShow={showEmbedded}
|
||||
onClose={() => setShowEmbedded(false)}
|
||||
appBaseUrl={app_base_url}
|
||||
accessToken={access_token}
|
||||
/>
|
||||
<CustomizeModal
|
||||
isShow={showCustomizeModal}
|
||||
linkUrl=""
|
||||
|
||||
3
web/app/components/app/overview/assets/code-browser.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.6667 6H1.33337M9.33337 11.6667L11 10L9.33337 8.33333M6.66671 8.33333L5.00004 10L6.66671 11.6667M1.33337 5.2L1.33337 10.8C1.33337 11.9201 1.33337 12.4802 1.55136 12.908C1.74311 13.2843 2.04907 13.5903 2.42539 13.782C2.85322 14 3.41327 14 4.53337 14H11.4667C12.5868 14 13.1469 14 13.5747 13.782C13.951 13.5903 14.257 13.2843 14.4487 12.908C14.6667 12.4802 14.6667 11.9201 14.6667 10.8V5.2C14.6667 4.0799 14.6667 3.51984 14.4487 3.09202C14.257 2.7157 13.951 2.40973 13.5747 2.21799C13.1469 2 12.5868 2 11.4667 2L4.53337 2C3.41327 2 2.85322 2 2.42539 2.21799C2.04907 2.40973 1.74311 2.71569 1.55136 3.09202C1.33337 3.51984 1.33337 4.0799 1.33337 5.2Z" stroke="#344054" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 850 B |
102
web/app/components/app/overview/assets/iframe-option.svg
Normal file
|
After Width: | Height: | Size: 43 KiB |
160
web/app/components/app/overview/assets/scripts-option.svg
Normal file
|
After Width: | Height: | Size: 47 KiB |
111
web/app/components/app/overview/embedded/index.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from 'classnames'
|
||||
import style from './style.module.css'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import useCopyToClipboard from '@/hooks/use-copy-to-clipboard'
|
||||
import copyStyle from '@/app/components/app/chat/copy-btn/style.module.css'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
// const isDevelopment = process.env.NODE_ENV === 'development'
|
||||
|
||||
type Props = {
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
accessToken: string
|
||||
appBaseUrl: string
|
||||
}
|
||||
|
||||
const OPTION_MAP = {
|
||||
iframe: {
|
||||
getContent: (url: string, token: string) =>
|
||||
`<iframe
|
||||
src="${url}/chatbot/${token}"
|
||||
style="width: 100%; height: 100%; min-height: 700px"
|
||||
frameborder="0"
|
||||
allow="microphone">
|
||||
</iframe>`,
|
||||
},
|
||||
scripts: {
|
||||
getContent: (url: string, token: string, isTestEnv?: boolean) =>
|
||||
`<script>
|
||||
window.difyChatbotConfig = { token: '${token}'${isTestEnv ? ', isDev: true' : ''} }
|
||||
</script>
|
||||
<script
|
||||
src="${url}/embed.min.js"
|
||||
id="${token}"
|
||||
defer>
|
||||
</script>`,
|
||||
},
|
||||
}
|
||||
const prefixEmbedded = 'appOverview.overview.appInfo.embedded'
|
||||
|
||||
type Option = keyof typeof OPTION_MAP
|
||||
|
||||
const Embedded = ({ isShow, onClose, appBaseUrl, accessToken }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [option, setOption] = useState<Option>('iframe')
|
||||
const [isCopied, setIsCopied] = useState({ iframe: false, scripts: false })
|
||||
const [_, copy] = useCopyToClipboard()
|
||||
|
||||
const { langeniusVersionInfo } = useAppContext()
|
||||
const isTestEnv = langeniusVersionInfo.current_env === 'TESTING' || langeniusVersionInfo.current_env === 'DEVELOPMENT'
|
||||
const onClickCopy = () => {
|
||||
copy(OPTION_MAP[option].getContent(appBaseUrl, accessToken, isTestEnv))
|
||||
setIsCopied({ ...isCopied, [option]: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t(`${prefixEmbedded}.title`)}
|
||||
isShow={isShow}
|
||||
onClose={onClose}
|
||||
className="!max-w-2xl w-[640px]"
|
||||
closable={true}
|
||||
>
|
||||
<div className="mb-4 mt-8 text-gray-900 text-[14px] font-medium leading-tight">
|
||||
{t(`${prefixEmbedded}.explanation`)}
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
{Object.keys(OPTION_MAP).map((v, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
style.option,
|
||||
style[`${v}Icon`],
|
||||
option === v && style.active,
|
||||
)}
|
||||
onClick={() => setOption(v as Option)}
|
||||
></div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-6 w-full bg-gray-100 rounded-lg flex-col justify-start items-start inline-flex">
|
||||
<div className="self-stretch pl-3 pr-1 py-1 bg-gray-50 rounded-tl-lg rounded-tr-lg border border-black border-opacity-5 justify-start items-center gap-2 inline-flex">
|
||||
<div className="grow shrink basis-0 text-slate-700 text-[13px] font-medium leading-none">
|
||||
{t(`${prefixEmbedded}.${option}`)}
|
||||
</div>
|
||||
<div className="p-2 rounded-lg justify-center items-center gap-1 flex">
|
||||
<Tooltip
|
||||
selector={'code-copy-feedback'}
|
||||
content={(isCopied[option] ? t(`${prefixEmbedded}.copied`) : t(`${prefixEmbedded}.copy`)) || ''}
|
||||
>
|
||||
<div className="w-8 h-8 cursor-pointer hover:bg-gray-100 rounded-lg">
|
||||
<div onClick={onClickCopy} className={`w-full h-full ${copyStyle.copyIcon} ${isCopied[option] ? copyStyle.copied : ''}`}></div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="self-stretch p-3 justify-start items-start gap-2 inline-flex">
|
||||
<div className="grow shrink basis-0 text-slate-700 text-[13px] leading-tight font-mono">
|
||||
<pre className='select-text'>{OPTION_MAP[option].getContent(appBaseUrl, accessToken, isTestEnv)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default Embedded
|
||||
14
web/app/components/app/overview/embedded/style.module.css
Normal file
@@ -0,0 +1,14 @@
|
||||
.option {
|
||||
width: 188px;
|
||||
height: 128px;
|
||||
@apply box-border cursor-pointer bg-auto bg-no-repeat bg-center rounded-md;
|
||||
}
|
||||
.active {
|
||||
@apply border-[1.5px] border-[#2970FF];
|
||||
}
|
||||
.iframeIcon {
|
||||
background-image: url(../assets/iframe-option.svg);
|
||||
}
|
||||
.scriptsIcon {
|
||||
background-image: url(../assets/scripts-option.svg);
|
||||
}
|
||||
@@ -11,3 +11,8 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.codeBrowserIcon {
|
||||
@apply w-4 h-4 bg-center bg-no-repeat;
|
||||
background-image: url(./assets/code-browser.svg);
|
||||
}
|
||||
|
||||
@@ -128,6 +128,9 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
||||
startQuerying()
|
||||
const res: any = await fetchMoreLikeThis(messageId as string, isInstalledApp, installedAppId)
|
||||
setCompletionRes(res.answer)
|
||||
setChildFeedback({
|
||||
rating: null,
|
||||
})
|
||||
setChildMessageId(res.id)
|
||||
stopQuerying()
|
||||
}
|
||||
@@ -152,6 +155,12 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
||||
}
|
||||
}, [controlClearMoreLikeThis])
|
||||
|
||||
// regeneration clear child
|
||||
useEffect(() => {
|
||||
if (isLoading)
|
||||
setChildMessageId(null)
|
||||
}, [isLoading])
|
||||
|
||||
return (
|
||||
<div className={cn(className, isTop ? 'rounded-xl border border-gray-200 bg-white' : 'rounded-br-xl !mt-0')}
|
||||
style={isTop
|
||||
|
||||
@@ -11,7 +11,7 @@ export const RFC_LOCALES = [
|
||||
{ value: 'en-US', name: 'EN' },
|
||||
{ value: 'zh-Hans', name: '简体中文' },
|
||||
]
|
||||
interface ISelectProps {
|
||||
type ISelectProps = {
|
||||
items: Array<{ value: string; name: string }>
|
||||
value?: string
|
||||
className?: string
|
||||
@@ -21,7 +21,7 @@ interface ISelectProps {
|
||||
export default function Select({
|
||||
items,
|
||||
value,
|
||||
onChange
|
||||
onChange,
|
||||
}: ISelectProps) {
|
||||
const item = items.filter(item => item.value === value)[0]
|
||||
|
||||
@@ -29,11 +29,12 @@ export default function Select({
|
||||
<div className="w-56 text-right">
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<Menu.Button className="inline-flex w-full justify-center items-center
|
||||
rounded-lg px-2 py-1
|
||||
text-gray-600 text-xs font-medium
|
||||
border border-gray-200">
|
||||
<GlobeAltIcon className="w-5 h-5 mr-2 " aria-hidden="true" />
|
||||
<Menu.Button className="inline-flex w-full h-[44px]justify-center items-center
|
||||
rounded-lg px-[10px] py-[6px]
|
||||
text-gray-900 text-[13px] font-medium
|
||||
border border-gray-200
|
||||
hover:bg-gray-100">
|
||||
<GlobeAltIcon className="w-5 h-5 mr-1" aria-hidden="true" />
|
||||
{item?.name}
|
||||
</Menu.Button>
|
||||
</div>
|
||||
@@ -46,14 +47,14 @@ export default function Select({
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 mt-2 w-28 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<Menu.Items className="absolute right-0 mt-2 w-[120px] origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="px-1 py-1 ">
|
||||
{items.map((item) => {
|
||||
return <Menu.Item key={item.value}>
|
||||
{({ active }) => (
|
||||
<button
|
||||
className={`${active ? 'bg-gray-100' : ''
|
||||
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
|
||||
} group flex w-full items-center rounded-lg px-3 py-2 text-sm text-gray-700`}
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault()
|
||||
onChange && onChange(item.value)
|
||||
@@ -77,7 +78,7 @@ export default function Select({
|
||||
export function InputSelect({
|
||||
items,
|
||||
value,
|
||||
onChange
|
||||
onChange,
|
||||
}: ISelectProps) {
|
||||
const item = items.filter(item => item.value === value)[0]
|
||||
return (
|
||||
@@ -104,7 +105,7 @@ export function InputSelect({
|
||||
{({ active }) => (
|
||||
<button
|
||||
className={`${active ? 'bg-gray-100' : ''
|
||||
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
|
||||
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
|
||||
onClick={() => {
|
||||
onChange && onChange(item.value)
|
||||
}}
|
||||
@@ -122,4 +123,4 @@ export function InputSelect({
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useParams, usePathname } from 'next/navigation'
|
||||
import cn from 'classnames'
|
||||
import Recorder from 'js-audio-recorder'
|
||||
import { useRafInterval } from 'ahooks'
|
||||
import { convertToMp3 } from './utils'
|
||||
import s from './index.module.css'
|
||||
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
|
||||
import { Loading02, XClose } from '@/app/components/base/icons/src/vender/line/general'
|
||||
@@ -19,7 +20,12 @@ const VoiceInput = ({
|
||||
onConverted,
|
||||
}: VoiceInputTypes) => {
|
||||
const { t } = useTranslation()
|
||||
const recorder = useRef(new Recorder())
|
||||
const recorder = useRef(new Recorder({
|
||||
sampleBits: 16,
|
||||
sampleRate: 16000,
|
||||
numChannels: 1,
|
||||
compiling: false,
|
||||
}))
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const ctxRef = useRef<CanvasRenderingContext2D | null>(null)
|
||||
const drawRecordId = useRef<number | null>(null)
|
||||
@@ -75,10 +81,10 @@ const VoiceInput = ({
|
||||
const canvas = canvasRef.current!
|
||||
const ctx = ctxRef.current!
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
const wavBlob = recorder.current.getWAVBlob()
|
||||
const wavFile = new File([wavBlob], 'a.wav', { type: 'audio/wav' })
|
||||
const mp3Blob = convertToMp3(recorder.current)
|
||||
const mp3File = new File([mp3Blob], 'temp.mp3', { type: 'audio/mp3' })
|
||||
const formData = new FormData()
|
||||
formData.append('file', wavFile)
|
||||
formData.append('file', mp3File)
|
||||
|
||||
let url = ''
|
||||
let isPublic = false
|
||||
|
||||
38
web/app/components/base/voice-input/utils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import lamejs from 'lamejs'
|
||||
|
||||
export const convertToMp3 = (recorder: any) => {
|
||||
const wav = lamejs.WavHeader.readHeader(recorder.getWAV())
|
||||
const { channels, sampleRate } = wav
|
||||
const mp3enc = new lamejs.Mp3Encoder(channels, sampleRate, 128)
|
||||
const result = recorder.getChannelData()
|
||||
const buffer = []
|
||||
|
||||
const leftData = result.left && new Int16Array(result.left.buffer, 0, result.left.byteLength / 2)
|
||||
const rightData = result.right && new Int16Array(result.right.buffer, 0, result.right.byteLength / 2)
|
||||
const remaining = leftData.length + (rightData ? rightData.length : 0)
|
||||
|
||||
const maxSamples = 1152
|
||||
for (let i = 0; i < remaining; i += maxSamples) {
|
||||
const left = leftData.subarray(i, i + maxSamples)
|
||||
let right = null
|
||||
let mp3buf = null
|
||||
|
||||
if (channels === 2) {
|
||||
right = rightData.subarray(i, i + maxSamples)
|
||||
mp3buf = mp3enc.encodeBuffer(left, right)
|
||||
}
|
||||
else {
|
||||
mp3buf = mp3enc.encodeBuffer(left)
|
||||
}
|
||||
|
||||
if (mp3buf.length > 0)
|
||||
buffer.push(mp3buf)
|
||||
}
|
||||
|
||||
const enc = mp3enc.flush()
|
||||
|
||||
if (enc.length > 0)
|
||||
buffer.push(enc)
|
||||
|
||||
return new Blob(buffer, { type: 'audio/mp3' })
|
||||
}
|
||||
@@ -138,7 +138,6 @@ For high-quality text generation, such as articles, summaries, and translations,
|
||||
"avatar_url": "https://assets.protocol.chat/avatars/frank.jpg",
|
||||
"display_name": null,
|
||||
"conversation_id": "xgQQXg3hrtjh7AvZ",
|
||||
"last_active_at": 705103200,
|
||||
"created_at": 692233200
|
||||
},
|
||||
{
|
||||
|
||||
@@ -138,7 +138,6 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
|
||||
"avatar_url": "https://assets.protocol.chat/avatars/frank.jpg",
|
||||
"display_name": null,
|
||||
"conversation_id": "xgQQXg3hrtjh7AvZ",
|
||||
"last_active_at": 705103200,
|
||||
"created_at": 692233200
|
||||
},
|
||||
{
|
||||
|
||||
@@ -137,12 +137,11 @@ For versatile conversational apps using a Q&A format, call the chat-messages API
|
||||
"data": [
|
||||
{
|
||||
"id": "WAz8eIbvDR60rouK",
|
||||
"username": "FrankMcCallister",
|
||||
"phone_number": "1-800-759-3000",
|
||||
"avatar_url": "https://assets.protocol.chat/avatars/frank.jpg",
|
||||
"display_name": null,
|
||||
"conversation_id": "xgQQXg3hrtjh7AvZ",
|
||||
"last_active_at": 705103200,
|
||||
"inputs": {},
|
||||
"query": "...",
|
||||
"answer": "...",
|
||||
"feedback": "like",
|
||||
"created_at": 692233200
|
||||
},
|
||||
{
|
||||
@@ -208,7 +207,6 @@ For versatile conversational apps using a Q&A format, call the chat-messages API
|
||||
"avatar_url": "https://assets.protocol.chat/avatars/frank.jpg",
|
||||
"display_name": null,
|
||||
"conversation_id": "xgQQXg3hrtjh7AvZ",
|
||||
"last_active_at": 705103200,
|
||||
"created_at": 692233200
|
||||
},
|
||||
{
|
||||
@@ -336,6 +334,53 @@ For versatile conversational apps using a Q&A format, call the chat-messages API
|
||||
</Row>
|
||||
|
||||
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/conversations/{converation_id}'
|
||||
method='DELETE'
|
||||
title='Conversation deletion'
|
||||
name='#delete'
|
||||
/>
|
||||
<Row>
|
||||
<Col>
|
||||
Delete conversation.
|
||||
|
||||
### Request Body
|
||||
|
||||
<Properties>
|
||||
<Property name='user' type='string' key='user'>
|
||||
The user identifier, defined by the developer, must ensure uniqueness within the app.
|
||||
</Property>
|
||||
</Properties>
|
||||
</Col>
|
||||
<Col sticky>
|
||||
|
||||
<CodeGroup title="Request" tag="DELETE" label="/conversations/{converation_id}" targetCode={`curl --location --request DELETE '${props.appDetail.api_base_url}/conversations/{conversation_id}' \\\n--header 'Authorization: Bearer ENTER-YOUR-SECRET-KEY' \\\n--header 'Content-Type: application/json' \\\n--data-raw '{ \n "user": "abc-123"\n}'`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl --location --request DELETE 'https://cloud.langgenius.dev/api/conversations/{convsation_id}' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Authorization: Bearer ENTER-YOUR-SECRET-KEY' \
|
||||
--data '{
|
||||
"user": "abc-123"
|
||||
}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
<CodeGroup title="Response">
|
||||
```json {{ title: 'Response' }}
|
||||
{
|
||||
"result": "success"
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
---
|
||||
|
||||
<Heading
|
||||
|
||||
@@ -142,7 +142,6 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
|
||||
"avatar_url": "https://assets.protocol.chat/avatars/frank.jpg",
|
||||
"display_name": null,
|
||||
"conversation_id": "xgQQXg3hrtjh7AvZ",
|
||||
"last_active_at": 705103200,
|
||||
"created_at": 692233200
|
||||
},
|
||||
{
|
||||
@@ -203,12 +202,11 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
|
||||
"data": [
|
||||
{
|
||||
"id": "WAz8eIbvDR60rouK",
|
||||
"username": "FrankMcCallister",
|
||||
"phone_number": "1-800-759-3000",
|
||||
"avatar_url": "https://assets.protocol.chat/avatars/frank.jpg",
|
||||
"display_name": null,
|
||||
"conversation_id": "xgQQXg3hrtjh7AvZ",
|
||||
"last_active_at": 705103200,
|
||||
"inputs": {},
|
||||
"query": "...",
|
||||
"answer": "...",
|
||||
"feedback": "like",
|
||||
"created_at": 692233200
|
||||
},
|
||||
{
|
||||
@@ -335,6 +333,52 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/conversations/{converation_id}'
|
||||
method='DELETE'
|
||||
title='删除会话'
|
||||
name='#delete'
|
||||
/>
|
||||
<Row>
|
||||
<Col>
|
||||
删除会话。
|
||||
|
||||
### Request Body
|
||||
|
||||
<Properties>
|
||||
<Property name='user' type='string' key='user'>
|
||||
用户标识,由开发者定义规则,需保证用户标识在应用内唯一。
|
||||
</Property>
|
||||
</Properties>
|
||||
</Col>
|
||||
<Col sticky>
|
||||
|
||||
<CodeGroup title="Request" tag="DELETE" label="/conversations/{converation_id}" targetCode={`curl --location --request DELETE '${props.appDetail.api_base_url}/conversations/{conversation_id}' \\\n--header 'Authorization: Bearer ENTER-YOUR-SECRET-KEY' \\\n--header 'Content-Type: application/json' \\\n--data-raw '{ \n "user": "abc-123"\n}'`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl --location --request DELETE 'https://cloud.langgenius.dev/api/conversations/{convsation_id}' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Authorization: Bearer ENTER-YOUR-SECRET-KEY' \
|
||||
--data '{
|
||||
"user": "abc-123"
|
||||
}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
<CodeGroup title="Response">
|
||||
```json {{ title: 'Response' }}
|
||||
{
|
||||
"result": "success"
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.logo-icon {
|
||||
background: url(../assets/logo-icon.png) center center no-repeat;
|
||||
background-size: contain;
|
||||
background-size: 32px;
|
||||
box-shadow: 0px 4px 6px -1px rgba(0, 0, 0, 0.05), 0px 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function AccountAbout({
|
||||
<div>
|
||||
<div className={classNames(
|
||||
s['logo-icon'],
|
||||
'mx-auto mb-3 w-12 h-12 bg-white rounded border border-gray-200',
|
||||
'mx-auto mb-3 w-12 h-12 bg-white rounded-xl border border-gray-200',
|
||||
)} />
|
||||
<div className={classNames(
|
||||
s['logo-text'],
|
||||
|
||||
@@ -25,13 +25,19 @@ const inputClassName = `
|
||||
text-sm font-normal text-gray-800
|
||||
`
|
||||
|
||||
const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
|
||||
|
||||
export default function AccountPage() {
|
||||
const { t } = useTranslation()
|
||||
const { mutateUserProfile, userProfile, apps } = useAppContext()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editing, setEditing] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const [editPasswordModalVisible, setEditPasswordModalVisible] = useState(false)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
|
||||
const handleEditName = () => {
|
||||
setEditNameModalVisible(true)
|
||||
@@ -52,6 +58,56 @@ export default function AccountPage() {
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showErrorMessage = (message: string) => {
|
||||
notify({
|
||||
type: 'error',
|
||||
message,
|
||||
})
|
||||
}
|
||||
const valid = () => {
|
||||
if (!password.trim()) {
|
||||
showErrorMessage(t('login.error.passwordEmpty'))
|
||||
return false
|
||||
}
|
||||
if (!validPassword.test(password))
|
||||
showErrorMessage(t('login.error.passwordInvalid'))
|
||||
if (password !== confirmPassword)
|
||||
showErrorMessage(t('common.account.notEqual'))
|
||||
|
||||
return true
|
||||
}
|
||||
const resetPasswordForm = () => {
|
||||
setCurrentPassword('')
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
}
|
||||
const handleSavePassowrd = async () => {
|
||||
if (!valid())
|
||||
return
|
||||
try {
|
||||
setEditing(true)
|
||||
await updateUserProfile({
|
||||
url: 'account/password',
|
||||
body: {
|
||||
password: currentPassword,
|
||||
new_password: password,
|
||||
repeat_new_password: confirmPassword,
|
||||
},
|
||||
})
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
mutateUserProfile()
|
||||
setEditPasswordModalVisible(false)
|
||||
resetPasswordForm()
|
||||
setEditing(false)
|
||||
}
|
||||
catch (e) {
|
||||
notify({ type: 'error', message: (e as Error).message })
|
||||
setEditPasswordModalVisible(false)
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderAppItem = (item: IItem) => {
|
||||
return (
|
||||
<div className='flex px-3 py-1'>
|
||||
@@ -80,51 +136,105 @@ export default function AccountPage() {
|
||||
<div className={titleClassName}>{t('common.account.email')}</div>
|
||||
<div className={classNames(inputClassName, 'cursor-pointer')}>{userProfile.email}</div>
|
||||
</div>
|
||||
{
|
||||
!!apps.length && (
|
||||
<>
|
||||
<div className='mb-6 border-[0.5px] border-gray-100' />
|
||||
<div className='mb-8'>
|
||||
<div className={titleClassName}>{t('common.account.langGeniusAccount')}</div>
|
||||
<div className={descriptionClassName}>{t('common.account.langGeniusAccountTip')}</div>
|
||||
<Collapse
|
||||
title={`${t('common.account.showAppLength', { length: apps.length })}`}
|
||||
items={apps.map(app => ({ key: app.id, name: app.name }))}
|
||||
renderItem={renderAppItem}
|
||||
wrapperClassName='mt-2'
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
editNameModalVisible && (
|
||||
<Modal
|
||||
isShow
|
||||
onClose={() => setEditNameModalVisible(false)}
|
||||
className={s.modal}
|
||||
>
|
||||
<div className='mb-6 text-lg font-medium text-gray-900'>{t('common.account.editName')}</div>
|
||||
<div className={titleClassName}>{t('common.account.name')}</div>
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
<div className='mb-8'>
|
||||
<div className='mb-1 text-sm font-medium text-gray-900'>{t('common.account.password')}</div>
|
||||
<div className='mb-2 text-xs text-gray-500'>{t('common.account.passwordTip')}</div>
|
||||
<Button className='font-medium !text-gray-700 !px-3 !py-[7px] !text-[13px]' onClick={() => setEditPasswordModalVisible(true)}>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</Button>
|
||||
</div>
|
||||
{!!apps.length && (
|
||||
<>
|
||||
<div className='mb-6 border-[0.5px] border-gray-100' />
|
||||
<div className='mb-8'>
|
||||
<div className={titleClassName}>{t('common.account.langGeniusAccount')}</div>
|
||||
<div className={descriptionClassName}>{t('common.account.langGeniusAccountTip')}</div>
|
||||
<Collapse
|
||||
title={`${t('common.account.showAppLength', { length: apps.length })}`}
|
||||
items={apps.map(app => ({ key: app.id, name: app.name }))}
|
||||
renderItem={renderAppItem}
|
||||
wrapperClassName='mt-2'
|
||||
/>
|
||||
<div className='flex justify-end mt-10'>
|
||||
<Button className='mr-2 text-sm font-medium' onClick={() => setEditNameModalVisible(false)}>{t('common.operation.cancel')}</Button>
|
||||
<Button
|
||||
disabled={editing || !editName}
|
||||
type='primary'
|
||||
className='text-sm font-medium'
|
||||
onClick={handleSaveName}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{editNameModalVisible && (
|
||||
<Modal
|
||||
isShow
|
||||
onClose={() => setEditNameModalVisible(false)}
|
||||
className={s.modal}
|
||||
>
|
||||
<div className='mb-6 text-lg font-medium text-gray-900'>{t('common.account.editName')}</div>
|
||||
<div className={titleClassName}>{t('common.account.name')}</div>
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
/>
|
||||
<div className='flex justify-end mt-10'>
|
||||
<Button className='mr-2 text-sm font-medium' onClick={() => setEditNameModalVisible(false)}>{t('common.operation.cancel')}</Button>
|
||||
<Button
|
||||
disabled={editing || !editName}
|
||||
type='primary'
|
||||
className='text-sm font-medium'
|
||||
onClick={handleSaveName}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
{editPasswordModalVisible && (
|
||||
<Modal
|
||||
isShow
|
||||
onClose={() => {
|
||||
setEditPasswordModalVisible(false)
|
||||
resetPasswordForm()
|
||||
}}
|
||||
className={s.modal}
|
||||
>
|
||||
<div className='mb-6 text-lg font-medium text-gray-900'>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</div>
|
||||
{userProfile.is_password_set && (
|
||||
<>
|
||||
<div className={titleClassName}>{t('common.account.currentPassword')}</div>
|
||||
<input
|
||||
type="password"
|
||||
className={inputClassName}
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className='mt-8 text-sm font-medium text-gray-900'>
|
||||
{userProfile.is_password_set ? t('common.account.newPassword') : t('common.account.password')}
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
className={inputClassName}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
<div className='mt-8 text-sm font-medium text-gray-900'>{t('common.account.confirmPassword')}</div>
|
||||
<input
|
||||
type="password"
|
||||
className={inputClassName}
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
<div className='flex justify-end mt-10'>
|
||||
<Button className='mr-2 text-sm font-medium' onClick={() => {
|
||||
setEditPasswordModalVisible(false)
|
||||
resetPasswordForm()
|
||||
}}>{t('common.operation.cancel')}</Button>
|
||||
<Button
|
||||
disabled={editing}
|
||||
type='primary'
|
||||
className='text-sm font-medium'
|
||||
onClick={handleSavePassowrd}
|
||||
>
|
||||
{userProfile.is_password_set ? t('common.operation.reset') : t('common.operation.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import cn from 'classnames'
|
||||
import { AtSymbolIcon, CubeTransparentIcon, GlobeAltIcon, UserIcon, UsersIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import { GlobeAltIcon as GlobalAltIconSolid, UserIcon as UserIconSolid, UsersIcon as UsersIconSolid } from '@heroicons/react/24/solid'
|
||||
import AccountPage from './account-page'
|
||||
@@ -18,6 +19,10 @@ const iconClassName = `
|
||||
w-4 h-4 ml-3 mr-2
|
||||
`
|
||||
|
||||
const scrolledClassName = `
|
||||
border-b shadow-xs bg-white/[.98]
|
||||
`
|
||||
|
||||
type IAccountSettingProps = {
|
||||
onCancel: () => void
|
||||
activeTab?: string
|
||||
@@ -78,6 +83,22 @@ export default function AccountSetting({
|
||||
],
|
||||
},
|
||||
]
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const scrollHandle = (e: any) => {
|
||||
if (e.target.scrollTop > 0)
|
||||
setScrolled(true)
|
||||
|
||||
else
|
||||
setScrolled(false)
|
||||
}
|
||||
useEffect(() => {
|
||||
const targetElement = scrollRef.current
|
||||
targetElement?.addEventListener('scroll', scrollHandle)
|
||||
return () => {
|
||||
targetElement?.removeEventListener('scroll', scrollHandle)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -115,29 +136,19 @@ export default function AccountSetting({
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className='w-[520px] h-[580px] px-6 py-4 overflow-y-auto'>
|
||||
<div className='flex items-center justify-between h-6 mb-8 text-base font-medium text-gray-900 '>
|
||||
<div ref={scrollRef} className='relative w-[520px] h-[580px] pb-4 overflow-y-auto'>
|
||||
<div className={cn('sticky top-0 px-6 py-4 flex items-center justify-between h-14 mb-4 bg-white text-base font-medium text-gray-900', scrolled && scrolledClassName)}>
|
||||
{[...menuItems[0].items, ...menuItems[1].items].find(item => item.key === activeMenu)?.name}
|
||||
<XMarkIcon className='w-4 h-4 cursor-pointer' onClick={onCancel} />
|
||||
</div>
|
||||
{
|
||||
activeMenu === 'account' && <AccountPage />
|
||||
}
|
||||
{
|
||||
activeMenu === 'members' && <MembersPage />
|
||||
}
|
||||
{
|
||||
activeMenu === 'integrations' && <IntegrationsPage />
|
||||
}
|
||||
{
|
||||
activeMenu === 'language' && <LanguagePage />
|
||||
}
|
||||
{
|
||||
activeMenu === 'provider' && <ProviderPage />
|
||||
}
|
||||
{
|
||||
activeMenu === 'data-source' && <DataSourcePage />
|
||||
}
|
||||
<div className='px-6'>
|
||||
{activeMenu === 'account' && <AccountPage />}
|
||||
{activeMenu === 'members' && <MembersPage />}
|
||||
{activeMenu === 'integrations' && <IntegrationsPage />}
|
||||
{activeMenu === 'language' && <LanguagePage />}
|
||||
{activeMenu === 'provider' && <ProviderPage />}
|
||||
{activeMenu === 'data-source' && <DataSourcePage />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -30,6 +30,7 @@ const MembersPage = () => {
|
||||
const { userProfile } = useAppContext()
|
||||
const { data, mutate } = useSWR({ url: '/workspaces/current/members' }, fetchMembers)
|
||||
const [inviteModalVisible, setInviteModalVisible] = useState(false)
|
||||
const [invitationLink, setInvitationLink] = useState('')
|
||||
const [invitedModalVisible, setInvitedModalVisible] = useState(false)
|
||||
const accounts = data?.accounts || []
|
||||
const owner = accounts.filter(account => account.role === 'owner')?.[0]?.email === userProfile.email
|
||||
@@ -93,8 +94,9 @@ const MembersPage = () => {
|
||||
inviteModalVisible && (
|
||||
<InviteModal
|
||||
onCancel={() => setInviteModalVisible(false)}
|
||||
onSend={() => {
|
||||
onSend={(url) => {
|
||||
setInvitedModalVisible(true)
|
||||
setInvitationLink(url)
|
||||
mutate()
|
||||
}}
|
||||
/>
|
||||
@@ -103,6 +105,7 @@ const MembersPage = () => {
|
||||
{
|
||||
invitedModalVisible && (
|
||||
<InvitedModal
|
||||
invitationLink={invitationLink}
|
||||
onCancel={() => setInvitedModalVisible(false)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -3,16 +3,16 @@ import { useState } from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import s from './index.module.css'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import s from './index.module.css'
|
||||
import { inviteMember } from '@/service/common'
|
||||
import { emailRegex } from '@/config'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
|
||||
interface IInviteModalProps {
|
||||
onCancel: () => void,
|
||||
onSend: () => void,
|
||||
type IInviteModalProps = {
|
||||
onCancel: () => void
|
||||
onSend: (url: string) => void
|
||||
}
|
||||
const InviteModal = ({
|
||||
onCancel,
|
||||
@@ -25,16 +25,16 @@ const InviteModal = ({
|
||||
const handleSend = async () => {
|
||||
if (emailRegex.test(email)) {
|
||||
try {
|
||||
const res = await inviteMember({ url: '/workspaces/current/members/invite-email', body: { email, role: 'admin'} })
|
||||
const res = await inviteMember({ url: '/workspaces/current/members/invite-email', body: { email, role: 'admin' } })
|
||||
|
||||
if (res.result === 'success') {
|
||||
onCancel()
|
||||
onSend()
|
||||
onSend(res.invite_url)
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
} else {
|
||||
catch (e) {}
|
||||
}
|
||||
else {
|
||||
notify({ type: 'error', message: t('common.members.emailInvalid') })
|
||||
}
|
||||
}
|
||||
@@ -51,15 +51,15 @@ const InviteModal = ({
|
||||
<div className='mb-2 text-sm font-medium text-gray-900'>{t('common.members.email')}</div>
|
||||
<input
|
||||
className='
|
||||
block w-full py-2 mb-9 px-3 bg-gray-50 outline-none border-none
|
||||
block w-full py-2 mb-9 px-3 bg-gray-50 outline-none border-none
|
||||
appearance-none text-sm text-gray-900 rounded-lg
|
||||
'
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder={t('common.members.emailPlaceholder') || ''}
|
||||
/>
|
||||
<Button
|
||||
className='w-full text-sm font-medium'
|
||||
<Button
|
||||
className='w-full text-sm font-medium'
|
||||
onClick={handleSend}
|
||||
type='primary'
|
||||
>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6665 2.66683C11.2865 2.66683 11.5965 2.66683 11.8508 2.73498C12.541 2.91991 13.0801 3.45901 13.265 4.14919C13.3332 4.40352 13.3332 4.71352 13.3332 5.3335V11.4668C13.3332 12.5869 13.3332 13.147 13.1152 13.5748C12.9234 13.9511 12.6175 14.2571 12.2412 14.4488C11.8133 14.6668 11.2533 14.6668 10.1332 14.6668H5.8665C4.7464 14.6668 4.18635 14.6668 3.75852 14.4488C3.3822 14.2571 3.07624 13.9511 2.88449 13.5748C2.6665 13.147 2.6665 12.5869 2.6665 11.4668V5.3335C2.6665 4.71352 2.6665 4.40352 2.73465 4.14919C2.91959 3.45901 3.45868 2.91991 4.14887 2.73498C4.4032 2.66683 4.71319 2.66683 5.33317 2.66683M5.99984 10.0002L7.33317 11.3335L10.3332 8.3335M6.39984 4.00016H9.59984C9.9732 4.00016 10.1599 4.00016 10.3025 3.9275C10.4279 3.86359 10.5299 3.7616 10.5938 3.63616C10.6665 3.49355 10.6665 3.30686 10.6665 2.9335V2.40016C10.6665 2.02679 10.6665 1.84011 10.5938 1.6975C10.5299 1.57206 10.4279 1.47007 10.3025 1.40616C10.1599 1.3335 9.97321 1.3335 9.59984 1.3335H6.39984C6.02647 1.3335 5.83978 1.3335 5.69718 1.40616C5.57174 1.47007 5.46975 1.57206 5.40583 1.6975C5.33317 1.84011 5.33317 2.02679 5.33317 2.40016V2.9335C5.33317 3.30686 5.33317 3.49355 5.40583 3.63616C5.46975 3.7616 5.57174 3.86359 5.69718 3.9275C5.83978 4.00016 6.02647 4.00016 6.39984 4.00016Z" stroke="#1D2939" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6665 2.66634H11.9998C12.3535 2.66634 12.6926 2.80682 12.9426 3.05687C13.1927 3.30691 13.3332 3.64605 13.3332 3.99967V13.333C13.3332 13.6866 13.1927 14.0258 12.9426 14.2758C12.6926 14.5259 12.3535 14.6663 11.9998 14.6663H3.99984C3.64622 14.6663 3.30708 14.5259 3.05703 14.2758C2.80698 14.0258 2.6665 13.6866 2.6665 13.333V3.99967C2.6665 3.64605 2.80698 3.30691 3.05703 3.05687C3.30708 2.80682 3.64622 2.66634 3.99984 2.66634H5.33317M5.99984 1.33301H9.99984C10.368 1.33301 10.6665 1.63148 10.6665 1.99967V3.33301C10.6665 3.7012 10.368 3.99967 9.99984 3.99967H5.99984C5.63165 3.99967 5.33317 3.7012 5.33317 3.33301V1.99967C5.33317 1.63148 5.63165 1.33301 5.99984 1.33301Z" stroke="#1D2939" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 875 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6665 2.66634H11.9998C12.3535 2.66634 12.6926 2.80682 12.9426 3.05687C13.1927 3.30691 13.3332 3.64605 13.3332 3.99967V13.333C13.3332 13.6866 13.1927 14.0258 12.9426 14.2758C12.6926 14.5259 12.3535 14.6663 11.9998 14.6663H3.99984C3.64622 14.6663 3.30708 14.5259 3.05703 14.2758C2.80698 14.0258 2.6665 13.6866 2.6665 13.333V3.99967C2.6665 3.64605 2.80698 3.30691 3.05703 3.05687C3.30708 2.80682 3.64622 2.66634 3.99984 2.66634H5.33317M5.99984 1.33301H9.99984C10.368 1.33301 10.6665 1.63148 10.6665 1.99967V3.33301C10.6665 3.7012 10.368 3.99967 9.99984 3.99967H5.99984C5.63165 3.99967 5.33317 3.7012 5.33317 3.33301V1.99967C5.33317 1.63148 5.63165 1.33301 5.99984 1.33301Z" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 875 B |
@@ -2,4 +2,20 @@
|
||||
padding: 32px !important;
|
||||
width: 480px !important;
|
||||
background: linear-gradient(180deg, rgba(3, 152, 85, 0.05) 0%, rgba(3, 152, 85, 0) 22.44%), #F9FAFB !important;
|
||||
}
|
||||
|
||||
.copyIcon {
|
||||
background-image: url(./assets/copy.svg);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.copyIcon:hover {
|
||||
background-image: url(./assets/copy-hover.svg);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.copyIcon.copied {
|
||||
background-image: url(./assets/copied.svg);
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/solid'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import InvitationLink from './invitation-link'
|
||||
import s from './index.module.css'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import s from './index.module.css'
|
||||
|
||||
interface IInvitedModalProps {
|
||||
onCancel: () => void,
|
||||
type IInvitedModalProps = {
|
||||
invitationLink: string
|
||||
onCancel: () => void
|
||||
}
|
||||
const InvitedModal = ({
|
||||
onCancel
|
||||
invitationLink,
|
||||
onCancel,
|
||||
}: IInvitedModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -27,10 +29,14 @@ const InvitedModal = ({
|
||||
<XMarkIcon className='w-4 h-4 cursor-pointer' onClick={onCancel} />
|
||||
</div>
|
||||
<div className='mb-1 text-xl font-semibold text-gray-900'>{t('common.members.invitationSent')}</div>
|
||||
<div className='mb-10 text-sm text-gray-500'>{t('common.members.invitationSentTip')}</div>
|
||||
<div className='mb-5 text-sm text-gray-500'>{t('common.members.invitationSentTip')}</div>
|
||||
<div className='mb-9'>
|
||||
<div className='py-2 text-sm font-Medium text-gray-900'>{t('common.members.invitationLink')}</div>
|
||||
<InvitationLink value={invitationLink} />
|
||||
</div>
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
className='w-[96px] text-sm font-medium'
|
||||
<Button
|
||||
className='w-[96px] text-sm font-medium'
|
||||
onClick={onCancel}
|
||||
type='primary'
|
||||
>
|
||||
@@ -42,4 +48,4 @@ const InvitedModal = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default InvitedModal
|
||||
export default InvitedModal
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { t } from 'i18next'
|
||||
import s from './index.module.css'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import useCopyToClipboard from '@/hooks/use-copy-to-clipboard'
|
||||
|
||||
type IInvitationLinkProps = {
|
||||
value?: string
|
||||
}
|
||||
|
||||
const InvitationLink = ({
|
||||
value = '',
|
||||
}: IInvitationLinkProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const [_, copy] = useCopyToClipboard()
|
||||
|
||||
const copyHandle = useCallback(() => {
|
||||
copy(value)
|
||||
setIsCopied(true)
|
||||
}, [value, copy])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied) {
|
||||
const timeout = setTimeout(() => {
|
||||
setIsCopied(false)
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [isCopied])
|
||||
|
||||
return (
|
||||
<div className='flex rounded-lg bg-gray-100 hover:bg-gray-100 border border-gray-200 py-2 items-center'>
|
||||
<div className="flex items-center flex-grow h-5">
|
||||
<div className='flex-grow bg-gray-100 text-[13px] relative h-full'>
|
||||
<Tooltip
|
||||
selector="top-uniq"
|
||||
content={isCopied ? `${t('appApi.copied')}` : `${t('appApi.copy')}`}
|
||||
className='z-10'
|
||||
>
|
||||
<div className='absolute top-0 left-0 w-full pl-2 pr-2 truncate cursor-pointer r-0' onClick={copyHandle}>{value}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex-shrink-0 h-4 bg-gray-200 border" />
|
||||
<Tooltip
|
||||
selector="top-uniq"
|
||||
content={isCopied ? `${t('appApi.copied')}` : `${t('appApi.copy')}`}
|
||||
className='z-10'
|
||||
>
|
||||
<div className="px-0.5 flex-shrink-0">
|
||||
<div className={`box-border w-[30px] h-[30px] flex items-center justify-center rounded-lg hover:bg-gray-100 cursor-pointer ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle}>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InvitationLink
|
||||
@@ -8,13 +8,26 @@ import { useContext } from 'use-context-selector'
|
||||
import produce from 'immer'
|
||||
import { useBoolean, useGetState } from 'ahooks'
|
||||
import AppUnavailable from '../../base/app-unavailable'
|
||||
import { checkOrSetAccessToken } from '../utils'
|
||||
import useConversation from './hooks/use-conversation'
|
||||
import s from './style.module.css'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import Sidebar from '@/app/components/share/chat/sidebar'
|
||||
import ConfigSence from '@/app/components/share/chat/config-scence'
|
||||
import Header from '@/app/components/share/header'
|
||||
import { delConversation, fetchAppInfo, fetchAppParams, fetchChatList, fetchConversations, fetchSuggestedQuestions, pinConversation, sendChatMessage, stopChatMessageResponding, unpinConversation, updateFeedback } from '@/service/share'
|
||||
import {
|
||||
delConversation,
|
||||
fetchAppInfo,
|
||||
fetchAppParams,
|
||||
fetchChatList,
|
||||
fetchConversations,
|
||||
fetchSuggestedQuestions,
|
||||
pinConversation,
|
||||
sendChatMessage,
|
||||
stopChatMessageResponding,
|
||||
unpinConversation,
|
||||
updateFeedback,
|
||||
} from '@/service/share'
|
||||
import type { ConversationItem, SiteInfo } from '@/models/share'
|
||||
import type { PromptConfig, SuggestedQuestionsAfterAnswerConfig } from '@/models/debug'
|
||||
import type { Feedbacktype, IChatItem } from '@/app/components/app/chat'
|
||||
@@ -296,7 +309,10 @@ const Main: FC<IMainProps> = ({
|
||||
return fetchConversations(isInstalledApp, installedAppInfo?.id, undefined, undefined, 100)
|
||||
}
|
||||
|
||||
const fetchInitData = () => {
|
||||
const fetchInitData = async () => {
|
||||
if (!isInstalledApp)
|
||||
await checkOrSetAccessToken()
|
||||
|
||||
return Promise.all([isInstalledApp
|
||||
? {
|
||||
app_id: installedAppInfo?.id,
|
||||
|
||||
13
web/app/components/share/chatbot/config-scence/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { IWelcomeProps } from '../welcome'
|
||||
import Welcome from '../welcome'
|
||||
|
||||
const ConfigScene: FC<IWelcomeProps> = (props) => {
|
||||
return (
|
||||
<div className='mb-5 antialiased font-sans shrink-0'>
|
||||
<Welcome {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigScene)
|
||||
70
web/app/components/share/chatbot/hooks/use-conversation.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState } from 'react'
|
||||
import produce from 'immer'
|
||||
import type { ConversationItem } from '@/models/share'
|
||||
|
||||
const storageConversationIdKey = 'conversationIdInfo'
|
||||
|
||||
type ConversationInfoType = Omit<ConversationItem, 'inputs' | 'id'>
|
||||
function useConversation() {
|
||||
const [conversationList, setConversationList] = useState<ConversationItem[]>([])
|
||||
const [pinnedConversationList, setPinnedConversationList] = useState<ConversationItem[]>([])
|
||||
const [currConversationId, doSetCurrConversationId] = useState<string>('-1')
|
||||
// when set conversation id, we do not have set appId
|
||||
const setCurrConversationId = (id: string, appId: string, isSetToLocalStroge = true, newConversationName = '') => {
|
||||
doSetCurrConversationId(id)
|
||||
if (isSetToLocalStroge && id !== '-1') {
|
||||
// conversationIdInfo: {[appId1]: conversationId1, [appId2]: conversationId2}
|
||||
const conversationIdInfo = globalThis.localStorage?.getItem(storageConversationIdKey) ? JSON.parse(globalThis.localStorage?.getItem(storageConversationIdKey) || '') : {}
|
||||
conversationIdInfo[appId] = id
|
||||
globalThis.localStorage?.setItem(storageConversationIdKey, JSON.stringify(conversationIdInfo))
|
||||
}
|
||||
}
|
||||
|
||||
const getConversationIdFromStorage = (appId: string) => {
|
||||
const conversationIdInfo = globalThis.localStorage?.getItem(storageConversationIdKey) ? JSON.parse(globalThis.localStorage?.getItem(storageConversationIdKey) || '') : {}
|
||||
const id = conversationIdInfo[appId]
|
||||
return id
|
||||
}
|
||||
|
||||
const isNewConversation = currConversationId === '-1'
|
||||
// input can be updated by user
|
||||
const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any> | null>(null)
|
||||
const resetNewConversationInputs = () => {
|
||||
if (!newConversationInputs)
|
||||
return
|
||||
setNewConversationInputs(produce(newConversationInputs, (draft) => {
|
||||
Object.keys(draft).forEach((key) => {
|
||||
draft[key] = ''
|
||||
})
|
||||
}))
|
||||
}
|
||||
const [existConversationInputs, setExistConversationInputs] = useState<Record<string, any> | null>(null)
|
||||
const currInputs = isNewConversation ? newConversationInputs : existConversationInputs
|
||||
const setCurrInputs = isNewConversation ? setNewConversationInputs : setExistConversationInputs
|
||||
|
||||
// info is muted
|
||||
const [newConversationInfo, setNewConversationInfo] = useState<ConversationInfoType | null>(null)
|
||||
const [existConversationInfo, setExistConversationInfo] = useState<ConversationInfoType | null>(null)
|
||||
const currConversationInfo = isNewConversation ? newConversationInfo : existConversationInfo
|
||||
|
||||
return {
|
||||
conversationList,
|
||||
setConversationList,
|
||||
pinnedConversationList,
|
||||
setPinnedConversationList,
|
||||
currConversationId,
|
||||
setCurrConversationId,
|
||||
getConversationIdFromStorage,
|
||||
isNewConversation,
|
||||
currInputs,
|
||||
newConversationInputs,
|
||||
existConversationInputs,
|
||||
resetNewConversationInputs,
|
||||
setCurrInputs,
|
||||
currConversationInfo,
|
||||
setNewConversationInfo,
|
||||
setExistConversationInfo,
|
||||
}
|
||||
}
|
||||
|
||||
export default useConversation
|
||||
647
web/app/components/share/chatbot/index.tsx
Normal file
@@ -0,0 +1,647 @@
|
||||
/* eslint-disable @typescript-eslint/no-use-before-define */
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import cn from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import produce from 'immer'
|
||||
import { useBoolean, useGetState } from 'ahooks'
|
||||
import { checkOrSetAccessToken } from '../utils'
|
||||
import AppUnavailable from '../../base/app-unavailable'
|
||||
import useConversation from './hooks/use-conversation'
|
||||
import s from './style.module.css'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import Sidebar from '@/app/components/share/chatbot/sidebar'
|
||||
import ConfigScene from '@/app/components/share/chatbot/config-scence'
|
||||
import Header from '@/app/components/share/header'
|
||||
import { /* delConversation, */ fetchAppInfo, fetchAppParams, fetchChatList, fetchConversations, fetchSuggestedQuestions, pinConversation, sendChatMessage, stopChatMessageResponding, unpinConversation, updateFeedback } from '@/service/share'
|
||||
import type { ConversationItem, SiteInfo } from '@/models/share'
|
||||
import type { PromptConfig, SuggestedQuestionsAfterAnswerConfig } from '@/models/debug'
|
||||
import type { Feedbacktype, IChatItem } from '@/app/components/app/chat'
|
||||
import Chat from '@/app/components/app/chat'
|
||||
import { changeLanguage } from '@/i18n/i18next-config'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { replaceStringWithValues } from '@/app/components/app/configuration/prompt-value-panel'
|
||||
import { userInputsFormToPromptVariables } from '@/utils/model-config'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
// import Confirm from '@/app/components/base/confirm'
|
||||
|
||||
export type IMainProps = {
|
||||
isInstalledApp?: boolean
|
||||
installedAppInfo?: InstalledApp
|
||||
}
|
||||
|
||||
const Main: FC<IMainProps> = ({
|
||||
isInstalledApp = false,
|
||||
installedAppInfo,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
|
||||
/*
|
||||
* app info
|
||||
*/
|
||||
const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
|
||||
const [isUnknwonReason, setIsUnknwonReason] = useState<boolean>(false)
|
||||
const [appId, setAppId] = useState<string>('')
|
||||
const [isPublicVersion, setIsPublicVersion] = useState<boolean>(true)
|
||||
const [siteInfo, setSiteInfo] = useState<SiteInfo | null>()
|
||||
const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
|
||||
const [inited, setInited] = useState<boolean>(false)
|
||||
const [plan, setPlan] = useState<string>('basic') // basic/plus/pro
|
||||
// in mobile, show sidebar by click button
|
||||
const [isShowSidebar, { setTrue: showSidebar, setFalse: hideSidebar }] = useBoolean(false)
|
||||
// Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client.
|
||||
useEffect(() => {
|
||||
if (siteInfo?.title) {
|
||||
if (plan !== 'basic')
|
||||
document.title = `${siteInfo.title}`
|
||||
else
|
||||
document.title = `${siteInfo.title} - Powered by Dify`
|
||||
}
|
||||
}, [siteInfo?.title, plan])
|
||||
|
||||
/*
|
||||
* conversation info
|
||||
*/
|
||||
const [allConversationList, setAllConversationList] = useState<ConversationItem[]>([])
|
||||
const [isClearConversationList, { setTrue: clearConversationListTrue, setFalse: clearConversationListFalse }] = useBoolean(false)
|
||||
const [isClearPinnedConversationList, { setTrue: clearPinnedConversationListTrue, setFalse: clearPinnedConversationListFalse }] = useBoolean(false)
|
||||
const {
|
||||
conversationList,
|
||||
setConversationList,
|
||||
pinnedConversationList,
|
||||
setPinnedConversationList,
|
||||
currConversationId,
|
||||
setCurrConversationId,
|
||||
getConversationIdFromStorage,
|
||||
isNewConversation,
|
||||
currConversationInfo,
|
||||
currInputs,
|
||||
newConversationInputs,
|
||||
// existConversationInputs,
|
||||
resetNewConversationInputs,
|
||||
setCurrInputs,
|
||||
setNewConversationInfo,
|
||||
setExistConversationInfo,
|
||||
} = useConversation()
|
||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||
const [hasPinnedMore, setHasPinnedMore] = useState<boolean>(true)
|
||||
|
||||
const onMoreLoaded = ({ data: conversations, has_more }: any) => {
|
||||
setHasMore(has_more)
|
||||
if (isClearConversationList) {
|
||||
setConversationList(conversations)
|
||||
clearConversationListFalse()
|
||||
}
|
||||
else {
|
||||
setConversationList([...conversationList, ...conversations])
|
||||
}
|
||||
}
|
||||
|
||||
const onPinnedMoreLoaded = ({ data: conversations, has_more }: any) => {
|
||||
setHasPinnedMore(has_more)
|
||||
if (isClearPinnedConversationList) {
|
||||
setPinnedConversationList(conversations)
|
||||
clearPinnedConversationListFalse()
|
||||
}
|
||||
else {
|
||||
setPinnedConversationList([...pinnedConversationList, ...conversations])
|
||||
}
|
||||
}
|
||||
|
||||
const [controlUpdateConversationList, setControlUpdateConversationList] = useState(0)
|
||||
|
||||
const noticeUpdateList = () => {
|
||||
setHasMore(true)
|
||||
clearConversationListTrue()
|
||||
|
||||
setHasPinnedMore(true)
|
||||
clearPinnedConversationListTrue()
|
||||
|
||||
setControlUpdateConversationList(Date.now())
|
||||
}
|
||||
|
||||
const handlePin = async (id: string) => {
|
||||
await pinConversation(isInstalledApp, installedAppInfo?.id, id)
|
||||
notify({ type: 'success', message: t('common.api.success') })
|
||||
noticeUpdateList()
|
||||
}
|
||||
|
||||
const handleUnpin = async (id: string) => {
|
||||
await unpinConversation(isInstalledApp, installedAppInfo?.id, id)
|
||||
notify({ type: 'success', message: t('common.api.success') })
|
||||
noticeUpdateList()
|
||||
}
|
||||
const [isShowConfirm, { setTrue: showConfirm, setFalse: hideConfirm }] = useBoolean(false)
|
||||
const [toDeleteConversationId, setToDeleteConversationId] = useState('')
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setToDeleteConversationId(id)
|
||||
hideSidebar() // mobile
|
||||
showConfirm()
|
||||
}
|
||||
|
||||
// const didDelete = async () => {
|
||||
// await delConversation(isInstalledApp, installedAppInfo?.id, toDeleteConversationId)
|
||||
// notify({ type: 'success', message: t('common.api.success') })
|
||||
// hideConfirm()
|
||||
// if (currConversationId === toDeleteConversationId)
|
||||
// handleConversationIdChange('-1')
|
||||
|
||||
// noticeUpdateList()
|
||||
// }
|
||||
|
||||
const [suggestedQuestionsAfterAnswerConfig, setSuggestedQuestionsAfterAnswerConfig] = useState<SuggestedQuestionsAfterAnswerConfig | null>(null)
|
||||
const [speechToTextConfig, setSpeechToTextConfig] = useState<SuggestedQuestionsAfterAnswerConfig | null>(null)
|
||||
|
||||
const [conversationIdChangeBecauseOfNew, setConversationIdChangeBecauseOfNew, getConversationIdChangeBecauseOfNew] = useGetState(false)
|
||||
const [isChatStarted, { setTrue: setChatStarted, setFalse: setChatNotStarted }] = useBoolean(false)
|
||||
const handleStartChat = (inputs: Record<string, any>) => {
|
||||
createNewChat()
|
||||
setConversationIdChangeBecauseOfNew(true)
|
||||
setCurrInputs(inputs)
|
||||
setChatStarted()
|
||||
// parse variables in introduction
|
||||
setChatList(generateNewChatListWithOpenstatement('', inputs))
|
||||
}
|
||||
const hasSetInputs = (() => {
|
||||
if (!isNewConversation)
|
||||
return true
|
||||
|
||||
return isChatStarted
|
||||
})()
|
||||
|
||||
// const conversationName = currConversationInfo?.name || t('share.chat.newChatDefaultName') as string
|
||||
const conversationIntroduction = currConversationInfo?.introduction || ''
|
||||
|
||||
const handleConversationSwitch = () => {
|
||||
if (!inited)
|
||||
return
|
||||
if (!appId) {
|
||||
// wait for appId
|
||||
setTimeout(handleConversationSwitch, 100)
|
||||
return
|
||||
}
|
||||
|
||||
// update inputs of current conversation
|
||||
let notSyncToStateIntroduction = ''
|
||||
let notSyncToStateInputs: Record<string, any> | undefined | null = {}
|
||||
if (!isNewConversation) {
|
||||
const item = allConversationList.find(item => item.id === currConversationId)
|
||||
notSyncToStateInputs = item?.inputs || {}
|
||||
setCurrInputs(notSyncToStateInputs)
|
||||
notSyncToStateIntroduction = item?.introduction || ''
|
||||
setExistConversationInfo({
|
||||
name: item?.name || '',
|
||||
introduction: notSyncToStateIntroduction,
|
||||
})
|
||||
}
|
||||
else {
|
||||
notSyncToStateInputs = newConversationInputs
|
||||
setCurrInputs(notSyncToStateInputs)
|
||||
}
|
||||
|
||||
// update chat list of current conversation
|
||||
if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponsing) {
|
||||
fetchChatList(currConversationId, isInstalledApp, installedAppInfo?.id).then((res: any) => {
|
||||
const { data } = res
|
||||
const newChatList: IChatItem[] = generateNewChatListWithOpenstatement(notSyncToStateIntroduction, notSyncToStateInputs)
|
||||
|
||||
data.forEach((item: any) => {
|
||||
newChatList.push({
|
||||
id: `question-${item.id}`,
|
||||
content: item.query,
|
||||
isAnswer: false,
|
||||
})
|
||||
newChatList.push({
|
||||
id: item.id,
|
||||
content: item.answer,
|
||||
feedback: item.feedback,
|
||||
isAnswer: true,
|
||||
})
|
||||
})
|
||||
setChatList(newChatList)
|
||||
})
|
||||
}
|
||||
|
||||
if (isNewConversation && isChatStarted)
|
||||
setChatList(generateNewChatListWithOpenstatement())
|
||||
|
||||
setControlFocus(Date.now())
|
||||
}
|
||||
useEffect(handleConversationSwitch, [currConversationId, inited])
|
||||
|
||||
const handleConversationIdChange = (id: string) => {
|
||||
if (id === '-1') {
|
||||
createNewChat()
|
||||
setConversationIdChangeBecauseOfNew(true)
|
||||
}
|
||||
else {
|
||||
setConversationIdChangeBecauseOfNew(false)
|
||||
}
|
||||
// trigger handleConversationSwitch
|
||||
setCurrConversationId(id, appId)
|
||||
setIsShowSuggestion(false)
|
||||
hideSidebar()
|
||||
}
|
||||
|
||||
/*
|
||||
* chat info. chat is under conversation.
|
||||
*/
|
||||
const [chatList, setChatList, getChatList] = useGetState<IChatItem[]>([])
|
||||
const chatListDomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// scroll to bottom
|
||||
if (chatListDomRef.current)
|
||||
chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight
|
||||
}, [chatList, currConversationId])
|
||||
// user can not edit inputs if user had send message
|
||||
const canEditInputs = !chatList.some(item => item.isAnswer === false) && isNewConversation
|
||||
const createNewChat = async () => {
|
||||
// if new chat is already exist, do not create new chat
|
||||
abortController?.abort()
|
||||
setResponsingFalse()
|
||||
if (conversationList.some(item => item.id === '-1'))
|
||||
return
|
||||
|
||||
setConversationList(produce(conversationList, (draft) => {
|
||||
draft.unshift({
|
||||
id: '-1',
|
||||
name: t('share.chat.newChatDefaultName'),
|
||||
inputs: newConversationInputs,
|
||||
introduction: conversationIntroduction,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
// sometime introduction is not applied to state
|
||||
const generateNewChatListWithOpenstatement = (introduction?: string, inputs?: Record<string, any> | null) => {
|
||||
let caculatedIntroduction = introduction || conversationIntroduction || ''
|
||||
const caculatedPromptVariables = inputs || currInputs || null
|
||||
if (caculatedIntroduction && caculatedPromptVariables)
|
||||
caculatedIntroduction = replaceStringWithValues(caculatedIntroduction, promptConfig?.prompt_variables || [], caculatedPromptVariables)
|
||||
|
||||
// console.log(isPublicVersion)
|
||||
const openstatement = {
|
||||
id: `${Date.now()}`,
|
||||
content: caculatedIntroduction,
|
||||
isAnswer: true,
|
||||
feedbackDisabled: true,
|
||||
isOpeningStatement: isPublicVersion,
|
||||
}
|
||||
if (caculatedIntroduction)
|
||||
return [openstatement]
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
const fetchAllConversations = () => {
|
||||
return fetchConversations(isInstalledApp, installedAppInfo?.id, undefined, undefined, 100)
|
||||
}
|
||||
|
||||
const fetchInitData = async () => {
|
||||
if (!isInstalledApp)
|
||||
await checkOrSetAccessToken()
|
||||
|
||||
return Promise.all([isInstalledApp
|
||||
? {
|
||||
app_id: installedAppInfo?.id,
|
||||
site: {
|
||||
title: installedAppInfo?.app.name,
|
||||
prompt_public: false,
|
||||
copyright: '',
|
||||
},
|
||||
plan: 'basic',
|
||||
}
|
||||
: fetchAppInfo(), fetchAllConversations(), fetchAppParams(isInstalledApp, installedAppInfo?.id)])
|
||||
}
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [appData, conversationData, appParams]: any = await fetchInitData()
|
||||
const { app_id: appId, site: siteInfo, plan }: any = appData
|
||||
setAppId(appId)
|
||||
setPlan(plan)
|
||||
const tempIsPublicVersion = siteInfo.prompt_public
|
||||
setIsPublicVersion(tempIsPublicVersion)
|
||||
const prompt_template = ''
|
||||
// handle current conversation id
|
||||
const { data: allConversations } = conversationData as { data: ConversationItem[]; has_more: boolean }
|
||||
const _conversationId = getConversationIdFromStorage(appId)
|
||||
const isNotNewConversation = allConversations.some(item => item.id === _conversationId)
|
||||
setAllConversationList(allConversations)
|
||||
// fetch new conversation info
|
||||
const { user_input_form, opening_statement: introduction, suggested_questions_after_answer, speech_to_text }: any = appParams
|
||||
const prompt_variables = userInputsFormToPromptVariables(user_input_form)
|
||||
if (siteInfo.default_language)
|
||||
changeLanguage(siteInfo.default_language)
|
||||
|
||||
setNewConversationInfo({
|
||||
name: t('share.chat.newChatDefaultName'),
|
||||
introduction,
|
||||
})
|
||||
setSiteInfo(siteInfo as SiteInfo)
|
||||
setPromptConfig({
|
||||
prompt_template,
|
||||
prompt_variables,
|
||||
} as PromptConfig)
|
||||
setSuggestedQuestionsAfterAnswerConfig(suggested_questions_after_answer)
|
||||
setSpeechToTextConfig(speech_to_text)
|
||||
|
||||
// setConversationList(conversations as ConversationItem[])
|
||||
|
||||
if (isNotNewConversation)
|
||||
setCurrConversationId(_conversationId, appId, false)
|
||||
|
||||
setInited(true)
|
||||
}
|
||||
catch (e: any) {
|
||||
if (e.status === 404) {
|
||||
setAppUnavailable(true)
|
||||
}
|
||||
else {
|
||||
setIsUnknwonReason(true)
|
||||
setAppUnavailable(true)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false)
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||
const { notify } = useContext(ToastContext)
|
||||
const logError = (message: string) => {
|
||||
notify({ type: 'error', message })
|
||||
}
|
||||
|
||||
const checkCanSend = () => {
|
||||
if (currConversationId !== '-1')
|
||||
return true
|
||||
|
||||
const prompt_variables = promptConfig?.prompt_variables
|
||||
const inputs = currInputs
|
||||
if (!inputs || !prompt_variables || prompt_variables?.length === 0)
|
||||
return true
|
||||
|
||||
let hasEmptyInput = false
|
||||
const requiredVars = prompt_variables?.filter(({ key, name, required }) => {
|
||||
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
|
||||
return res
|
||||
}) || [] // compatible with old version
|
||||
requiredVars.forEach(({ key }) => {
|
||||
if (hasEmptyInput)
|
||||
return
|
||||
|
||||
if (!inputs?.[key])
|
||||
hasEmptyInput = true
|
||||
})
|
||||
|
||||
if (hasEmptyInput) {
|
||||
logError(t('appDebug.errorMessage.valueOfVarRequired'))
|
||||
return false
|
||||
}
|
||||
return !hasEmptyInput
|
||||
}
|
||||
|
||||
const [controlFocus, setControlFocus] = useState(0)
|
||||
const [isShowSuggestion, setIsShowSuggestion] = useState(false)
|
||||
const doShowSuggestion = isShowSuggestion && !isResponsing
|
||||
const [suggestQuestions, setSuggestQuestions] = useState<string[]>([])
|
||||
const [messageTaskId, setMessageTaskId] = useState('')
|
||||
const [hasStopResponded, setHasStopResponded, getHasStopResponded] = useGetState(false)
|
||||
|
||||
const handleSend = async (message: string) => {
|
||||
if (isResponsing) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
|
||||
return
|
||||
}
|
||||
const data = {
|
||||
inputs: currInputs,
|
||||
query: message,
|
||||
conversation_id: isNewConversation ? null : currConversationId,
|
||||
}
|
||||
|
||||
// qustion
|
||||
const questionId = `question-${Date.now()}`
|
||||
const questionItem = {
|
||||
id: questionId,
|
||||
content: message,
|
||||
isAnswer: false,
|
||||
}
|
||||
|
||||
const placeholderAnswerId = `answer-placeholder-${Date.now()}`
|
||||
const placeholderAnswerItem = {
|
||||
id: placeholderAnswerId,
|
||||
content: '',
|
||||
isAnswer: true,
|
||||
}
|
||||
|
||||
const newList = [...getChatList(), questionItem, placeholderAnswerItem]
|
||||
setChatList(newList)
|
||||
|
||||
// answer
|
||||
const responseItem = {
|
||||
id: `${Date.now()}`,
|
||||
content: '',
|
||||
isAnswer: true,
|
||||
}
|
||||
|
||||
let tempNewConversationId = ''
|
||||
|
||||
setHasStopResponded(false)
|
||||
setResponsingTrue()
|
||||
setIsShowSuggestion(false)
|
||||
sendChatMessage(data, {
|
||||
getAbortController: (abortController) => {
|
||||
setAbortController(abortController)
|
||||
},
|
||||
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
|
||||
responseItem.content = responseItem.content + message
|
||||
responseItem.id = messageId
|
||||
if (isFirstMessage && newConversationId)
|
||||
tempNewConversationId = newConversationId
|
||||
|
||||
setMessageTaskId(taskId)
|
||||
// closesure new list is outdated.
|
||||
const newListWithAnswer = produce(
|
||||
getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
|
||||
(draft) => {
|
||||
if (!draft.find(item => item.id === questionId))
|
||||
draft.push({ ...questionItem })
|
||||
|
||||
draft.push({ ...responseItem })
|
||||
})
|
||||
setChatList(newListWithAnswer)
|
||||
},
|
||||
async onCompleted(hasError?: boolean) {
|
||||
setResponsingFalse()
|
||||
if (hasError)
|
||||
return
|
||||
|
||||
if (getConversationIdChangeBecauseOfNew()) {
|
||||
const { data: allConversations }: any = await fetchAllConversations()
|
||||
setAllConversationList(allConversations)
|
||||
noticeUpdateList()
|
||||
}
|
||||
setConversationIdChangeBecauseOfNew(false)
|
||||
resetNewConversationInputs()
|
||||
setChatNotStarted()
|
||||
setCurrConversationId(tempNewConversationId, appId, true)
|
||||
if (suggestedQuestionsAfterAnswerConfig?.enabled && !getHasStopResponded()) {
|
||||
const { data }: any = await fetchSuggestedQuestions(responseItem.id, isInstalledApp, installedAppInfo?.id)
|
||||
setSuggestQuestions(data)
|
||||
setIsShowSuggestion(true)
|
||||
}
|
||||
},
|
||||
onError() {
|
||||
setResponsingFalse()
|
||||
// role back placeholder answer
|
||||
setChatList(produce(getChatList(), (draft) => {
|
||||
draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1)
|
||||
}))
|
||||
},
|
||||
}, isInstalledApp, installedAppInfo?.id)
|
||||
}
|
||||
|
||||
const handleFeedback = async (messageId: string, feedback: Feedbacktype) => {
|
||||
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }, isInstalledApp, installedAppInfo?.id)
|
||||
const newChatList = chatList.map((item) => {
|
||||
if (item.id === messageId) {
|
||||
return {
|
||||
...item,
|
||||
feedback,
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
setChatList(newChatList)
|
||||
notify({ type: 'success', message: t('common.api.success') })
|
||||
}
|
||||
|
||||
const renderSidebar = () => {
|
||||
if (!appId || !siteInfo || !promptConfig)
|
||||
return null
|
||||
return (
|
||||
<Sidebar
|
||||
list={conversationList}
|
||||
isClearConversationList={isClearConversationList}
|
||||
pinnedList={pinnedConversationList}
|
||||
isClearPinnedConversationList={isClearPinnedConversationList}
|
||||
onMoreLoaded={onMoreLoaded}
|
||||
onPinnedMoreLoaded={onPinnedMoreLoaded}
|
||||
isNoMore={!hasMore}
|
||||
isPinnedNoMore={!hasPinnedMore}
|
||||
onCurrentIdChange={handleConversationIdChange}
|
||||
currentId={currConversationId}
|
||||
copyRight={siteInfo.copyright || siteInfo.title}
|
||||
isInstalledApp={isInstalledApp}
|
||||
installedAppId={installedAppInfo?.id}
|
||||
siteInfo={siteInfo}
|
||||
onPin={handlePin}
|
||||
onUnpin={handleUnpin}
|
||||
controlUpdateList={controlUpdateConversationList}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (appUnavailable)
|
||||
return <AppUnavailable isUnknwonReason={isUnknwonReason} />
|
||||
|
||||
if (!appId || !siteInfo || !promptConfig)
|
||||
return <Loading type='app' />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header
|
||||
title={siteInfo.title}
|
||||
icon={siteInfo.icon || ''}
|
||||
icon_background={siteInfo.icon_background}
|
||||
isEmbedScene={true}
|
||||
isMobile={isMobile}
|
||||
// onShowSideBar={showSidebar}
|
||||
// onCreateNewChat={() => handleConversationIdChange('-1')}
|
||||
/>
|
||||
|
||||
<div className={'flex bg-white overflow-hidden'}>
|
||||
{/* sidebar */}
|
||||
{/* {!isMobile && renderSidebar()} */}
|
||||
{/* {isMobile && isShowSidebar && (
|
||||
<div className='fixed inset-0 z-50'
|
||||
style={{ backgroundColor: 'rgba(35, 56, 118, 0.2)' }}
|
||||
onClick={hideSidebar}
|
||||
>
|
||||
<div className='inline-block' onClick={e => e.stopPropagation()}>
|
||||
{renderSidebar()}
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
{/* main */}
|
||||
<div className={cn(
|
||||
isInstalledApp ? s.installedApp : 'h-[calc(100vh_-_3rem)]',
|
||||
'flex-grow flex flex-col overflow-y-auto',
|
||||
)
|
||||
}>
|
||||
<ConfigScene
|
||||
// conversationName={conversationName}
|
||||
hasSetInputs={hasSetInputs}
|
||||
isPublicVersion={isPublicVersion}
|
||||
siteInfo={siteInfo}
|
||||
promptConfig={promptConfig}
|
||||
onStartChat={handleStartChat}
|
||||
canEditInputs={canEditInputs}
|
||||
savedInputs={currInputs as Record<string, any>}
|
||||
onInputsChange={setCurrInputs}
|
||||
plan={plan}
|
||||
></ConfigScene>
|
||||
|
||||
{
|
||||
hasSetInputs && (
|
||||
<div className={cn(doShowSuggestion ? 'pb-[140px]' : (isResponsing ? 'pb-[113px]' : 'pb-[66px]'), 'relative grow h-[200px] pc:w-[794px] max-w-full mobile:w-full mx-auto mb-3.5 overflow-hidden')}>
|
||||
<div className='h-full overflow-y-auto' ref={chatListDomRef}>
|
||||
<Chat
|
||||
chatList={chatList}
|
||||
onSend={handleSend}
|
||||
isHideFeedbackEdit
|
||||
onFeedback={handleFeedback}
|
||||
isResponsing={isResponsing}
|
||||
canStopResponsing={!!messageTaskId}
|
||||
abortResponsing={async () => {
|
||||
await stopChatMessageResponding(appId, messageTaskId, isInstalledApp, installedAppInfo?.id)
|
||||
setHasStopResponded(true)
|
||||
setResponsingFalse()
|
||||
}}
|
||||
checkCanSend={checkCanSend}
|
||||
controlFocus={controlFocus}
|
||||
isShowSuggestion={doShowSuggestion}
|
||||
suggestionList={suggestQuestions}
|
||||
displayScene='web'
|
||||
isShowSpeechToText={speechToTextConfig?.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
|
||||
{/* {isShowConfirm && (
|
||||
<Confirm
|
||||
title={t('share.chat.deleteConversation.title')}
|
||||
content={t('share.chat.deleteConversation.content')}
|
||||
isShow={isShowConfirm}
|
||||
onClose={hideConfirm}
|
||||
onConfirm={didDelete}
|
||||
onCancel={hideConfirm}
|
||||
/>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Main)
|
||||
28
web/app/components/share/chatbot/sidebar/app-info/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import cn from 'classnames'
|
||||
import { appDefaultIconBackground } from '@/config/index'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
|
||||
export type IAppInfoProps = {
|
||||
className?: string
|
||||
icon: string
|
||||
icon_background?: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const AppInfo: FC<IAppInfoProps> = ({
|
||||
className,
|
||||
icon,
|
||||
icon_background,
|
||||
name,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(className, 'flex items-center space-x-3')}>
|
||||
<AppIcon size="small" icon={icon} background={icon_background || appDefaultIconBackground} />
|
||||
<div className='w-0 grow text-sm font-semibold text-gray-800 overflow-hidden text-ellipsis whitespace-nowrap'>{name}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(AppInfo)
|
||||
3
web/app/components/share/chatbot/sidebar/card.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.card:hover {
|
||||
background: linear-gradient(0deg, rgba(235, 245, 255, 0.4), rgba(235, 245, 255, 0.4)), #FFFFFF;
|
||||
}
|
||||
19
web/app/components/share/chatbot/sidebar/card.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import s from './card.module.css'
|
||||
|
||||
type PropType = {
|
||||
children: React.ReactNode
|
||||
text?: string
|
||||
}
|
||||
function Card({ children, text }: PropType) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={`${s.card} box-border w-full flex flex-col items-start px-4 py-3 rounded-lg border-solid border border-gray-200 cursor-pointer hover:border-primary-300`}>
|
||||
<div className='text-gray-400 font-medium text-xs mb-2'>{text ?? t('share.chat.powerBy')}</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Card
|
||||
151
web/app/components/share/chatbot/sidebar/index.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PencilSquareIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import cn from 'classnames'
|
||||
import Button from '../../../base/button'
|
||||
import List from './list'
|
||||
import AppInfo from '@/app/components/share/chat/sidebar/app-info'
|
||||
// import Card from './card'
|
||||
import type { ConversationItem, SiteInfo } from '@/models/share'
|
||||
import { fetchConversations } from '@/service/share'
|
||||
|
||||
export type ISidebarProps = {
|
||||
copyRight: string
|
||||
currentId: string
|
||||
onCurrentIdChange: (id: string) => void
|
||||
list: ConversationItem[]
|
||||
isClearConversationList: boolean
|
||||
pinnedList: ConversationItem[]
|
||||
isClearPinnedConversationList: boolean
|
||||
isInstalledApp: boolean
|
||||
installedAppId?: string
|
||||
siteInfo: SiteInfo
|
||||
onMoreLoaded: (res: { data: ConversationItem[]; has_more: boolean }) => void
|
||||
onPinnedMoreLoaded: (res: { data: ConversationItem[]; has_more: boolean }) => void
|
||||
isNoMore: boolean
|
||||
isPinnedNoMore: boolean
|
||||
onPin: (id: string) => void
|
||||
onUnpin: (id: string) => void
|
||||
controlUpdateList: number
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
const Sidebar: FC<ISidebarProps> = ({
|
||||
copyRight,
|
||||
currentId,
|
||||
onCurrentIdChange,
|
||||
list,
|
||||
isClearConversationList,
|
||||
pinnedList,
|
||||
isClearPinnedConversationList,
|
||||
isInstalledApp,
|
||||
installedAppId,
|
||||
siteInfo,
|
||||
onMoreLoaded,
|
||||
onPinnedMoreLoaded,
|
||||
isNoMore,
|
||||
isPinnedNoMore,
|
||||
onPin,
|
||||
onUnpin,
|
||||
controlUpdateList,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [hasPinned, setHasPinned] = useState(false)
|
||||
|
||||
const checkHasPinned = async () => {
|
||||
const { data }: any = await fetchConversations(isInstalledApp, installedAppId, undefined, true)
|
||||
setHasPinned(data.length > 0)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
checkHasPinned()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (controlUpdateList !== 0)
|
||||
checkHasPinned()
|
||||
}, [controlUpdateList])
|
||||
|
||||
const maxListHeight = isInstalledApp ? 'max-h-[30vh]' : 'max-h-[40vh]'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
cn(
|
||||
isInstalledApp ? 'tablet:h-[calc(100vh_-_74px)]' : 'tablet:h-[calc(100vh_-_3rem)]',
|
||||
'shrink-0 flex flex-col bg-white pc:w-[244px] tablet:w-[192px] mobile:w-[240px] border-r border-gray-200 mobile:h-screen',
|
||||
)
|
||||
}
|
||||
>
|
||||
{isInstalledApp && (
|
||||
<AppInfo
|
||||
className='my-4 px-4'
|
||||
name={siteInfo.title || ''}
|
||||
icon={siteInfo.icon || ''}
|
||||
icon_background={siteInfo.icon_background}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-shrink-0 p-4 !pb-0">
|
||||
<Button
|
||||
onClick={() => { onCurrentIdChange('-1') }}
|
||||
className="group block w-full flex-shrink-0 !justify-start !h-9 text-primary-600 items-center text-sm">
|
||||
<PencilSquareIcon className="mr-2 h-4 w-4" /> {t('share.chat.newChat')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={'flex-grow flex flex-col h-0 overflow-y-auto overflow-x-hidden'}>
|
||||
{/* pinned list */}
|
||||
{hasPinned && (
|
||||
<div className={cn('mt-4 px-4', list.length === 0 && 'flex flex-col flex-grow')}>
|
||||
<div className='mb-1.5 leading-[18px] text-xs text-gray-500 font-medium uppercase'>{t('share.chat.pinnedTitle')}</div>
|
||||
<List
|
||||
className={cn(list.length > 0 ? maxListHeight : 'flex-grow')}
|
||||
currentId={currentId}
|
||||
onCurrentIdChange={onCurrentIdChange}
|
||||
list={pinnedList}
|
||||
isClearConversationList={isClearPinnedConversationList}
|
||||
isInstalledApp={isInstalledApp}
|
||||
installedAppId={installedAppId}
|
||||
onMoreLoaded={onPinnedMoreLoaded}
|
||||
isNoMore={isPinnedNoMore}
|
||||
isPinned={true}
|
||||
onPinChanged={id => onUnpin(id)}
|
||||
controlUpdate={controlUpdateList + 1}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* unpinned list */}
|
||||
<div className={cn('mt-4 px-4', !hasPinned && 'flex flex-col flex-grow')}>
|
||||
{(hasPinned && list.length > 0) && (
|
||||
<div className='mb-1.5 leading-[18px] text-xs text-gray-500 font-medium uppercase'>{t('share.chat.unpinnedTitle')}</div>
|
||||
)}
|
||||
<List
|
||||
className={cn(hasPinned ? maxListHeight : 'flex-grow')}
|
||||
currentId={currentId}
|
||||
onCurrentIdChange={onCurrentIdChange}
|
||||
list={list}
|
||||
isClearConversationList={isClearConversationList}
|
||||
isInstalledApp={isInstalledApp}
|
||||
installedAppId={installedAppId}
|
||||
onMoreLoaded={onMoreLoaded}
|
||||
isNoMore={isNoMore}
|
||||
isPinned={false}
|
||||
onPinChanged={id => onPin(id)}
|
||||
controlUpdate={controlUpdateList + 1}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 pr-4 pb-4 pl-4">
|
||||
<div className="text-gray-400 font-normal text-xs">© {copyRight} {(new Date()).getFullYear()}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Sidebar)
|
||||
115
web/app/components/share/chatbot/sidebar/list/index.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useRef } from 'react'
|
||||
import {
|
||||
ChatBubbleOvalLeftEllipsisIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { useInfiniteScroll } from 'ahooks'
|
||||
import { ChatBubbleOvalLeftEllipsisIcon as ChatBubbleOvalLeftEllipsisSolidIcon } from '@heroicons/react/24/solid'
|
||||
import cn from 'classnames'
|
||||
import s from './style.module.css'
|
||||
import type { ConversationItem } from '@/models/share'
|
||||
import { fetchConversations } from '@/service/share'
|
||||
import ItemOperation from '@/app/components/explore/item-operation'
|
||||
|
||||
export type IListProps = {
|
||||
className: string
|
||||
currentId: string
|
||||
onCurrentIdChange: (id: string) => void
|
||||
list: ConversationItem[]
|
||||
isClearConversationList: boolean
|
||||
isInstalledApp: boolean
|
||||
installedAppId?: string
|
||||
onMoreLoaded: (res: { data: ConversationItem[]; has_more: boolean }) => void
|
||||
isNoMore: boolean
|
||||
isPinned: boolean
|
||||
onPinChanged: (id: string) => void
|
||||
controlUpdate: number
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
const List: FC<IListProps> = ({
|
||||
className,
|
||||
currentId,
|
||||
onCurrentIdChange,
|
||||
list,
|
||||
isClearConversationList,
|
||||
isInstalledApp,
|
||||
installedAppId,
|
||||
onMoreLoaded,
|
||||
isNoMore,
|
||||
isPinned,
|
||||
onPinChanged,
|
||||
controlUpdate,
|
||||
onDelete,
|
||||
}) => {
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useInfiniteScroll(
|
||||
async () => {
|
||||
if (!isNoMore) {
|
||||
const lastId = !isClearConversationList ? list[list.length - 1]?.id : undefined
|
||||
const { data: conversations, has_more }: any = await fetchConversations(isInstalledApp, installedAppId, lastId, isPinned)
|
||||
onMoreLoaded({ data: conversations, has_more })
|
||||
}
|
||||
return { list: [] }
|
||||
},
|
||||
{
|
||||
target: listRef,
|
||||
isNoMore: () => {
|
||||
return isNoMore
|
||||
},
|
||||
reloadDeps: [isNoMore, controlUpdate],
|
||||
},
|
||||
)
|
||||
return (
|
||||
<nav
|
||||
ref={listRef}
|
||||
className={cn(className, 'shrink-0 space-y-1 bg-white pb-[85px] overflow-y-auto')}
|
||||
>
|
||||
{list.map((item) => {
|
||||
const isCurrent = item.id === currentId
|
||||
const ItemIcon
|
||||
= isCurrent ? ChatBubbleOvalLeftEllipsisSolidIcon : ChatBubbleOvalLeftEllipsisIcon
|
||||
return (
|
||||
<div
|
||||
onClick={() => onCurrentIdChange(item.id)}
|
||||
key={item.id}
|
||||
className={cn(s.item,
|
||||
isCurrent
|
||||
? 'bg-primary-50 text-primary-600'
|
||||
: 'text-gray-700 hover:bg-gray-200 hover:text-gray-700',
|
||||
'group flex justify-between items-center rounded-md px-2 py-2 text-sm font-medium cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center w-0 grow'>
|
||||
<ItemIcon
|
||||
className={cn(
|
||||
isCurrent
|
||||
? 'text-primary-600'
|
||||
: 'text-gray-400 group-hover:text-gray-500',
|
||||
'mr-3 h-5 w-5 flex-shrink-0',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
|
||||
{item.id !== '-1' && (
|
||||
<div className={cn(s.opBtn, 'shrink-0')} onClick={e => e.stopPropagation()}>
|
||||
<ItemOperation
|
||||
isPinned={isPinned}
|
||||
togglePin={() => onPinChanged(item.id)}
|
||||
isShowDelete
|
||||
onDelete={() => onDelete(item.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(List)
|
||||
@@ -0,0 +1,7 @@
|
||||
.opBtn {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.item:hover .opBtn {
|
||||
visibility: visible;
|
||||
}
|
||||
3
web/app/components/share/chatbot/style.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.installedApp {
|
||||
height: calc(100vh - 74px);
|
||||
}
|
||||
79
web/app/components/share/chatbot/value-panel/index.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import cn from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import s from './style.module.css'
|
||||
import { StarIcon } from '@/app/components/share/chatbot/welcome/massive-component'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
export type ITemplateVarPanelProps = {
|
||||
className?: string
|
||||
header: ReactNode
|
||||
children?: ReactNode | null
|
||||
isFold: boolean
|
||||
}
|
||||
|
||||
const TemplateVarPanel: FC<ITemplateVarPanelProps> = ({
|
||||
className,
|
||||
header,
|
||||
children,
|
||||
isFold,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(isFold ? 'border border-indigo-100' : s.boxShodow, className, 'rounded-xl ')}>
|
||||
{/* header */}
|
||||
<div
|
||||
className={cn(isFold && 'rounded-b-xl', 'rounded-t-xl px-6 py-4 bg-indigo-25 text-xs')}
|
||||
>
|
||||
{header}
|
||||
</div>
|
||||
{/* body */}
|
||||
{!isFold && children && (
|
||||
<div className='rounded-b-xl p-6'>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PanelTitle: FC<{ title: string; className?: string }> = ({
|
||||
title,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(className, 'flex items-center space-x-1 text-indigo-600')}>
|
||||
<StarIcon />
|
||||
<span className='text-xs'>{title}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VarOpBtnGroup: FC<{ className?: string; onConfirm: () => void; onCancel: () => void }> = ({
|
||||
className,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={cn(className, 'flex mt-3 space-x-2 mobile:ml-0 tablet:ml-[128px] text-sm')}>
|
||||
<Button
|
||||
className='text-sm'
|
||||
type='primary'
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
<Button
|
||||
className='text-sm'
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(TemplateVarPanel)
|
||||
@@ -0,0 +1,3 @@
|
||||
.boxShodow {
|
||||
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||
}
|
||||
BIN
web/app/components/share/chatbot/welcome/icons/logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
356
web/app/components/share/chatbot/welcome/index.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import TemplateVarPanel, { PanelTitle, VarOpBtnGroup } from '../value-panel'
|
||||
import s from './style.module.css'
|
||||
import { AppInfo, ChatBtn, EditBtn, FootLogo, PromptTemplate } from './massive-component'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import type { PromptConfig } from '@/models/debug'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import Select from '@/app/components/base/select'
|
||||
import { DEFAULT_VALUE_MAX_LEN } from '@/config'
|
||||
|
||||
// regex to match the {{}} and replace it with a span
|
||||
const regex = /\{\{([^}]+)\}\}/g
|
||||
|
||||
export type IWelcomeProps = {
|
||||
// conversationName: string
|
||||
hasSetInputs: boolean
|
||||
isPublicVersion: boolean
|
||||
siteInfo: SiteInfo
|
||||
promptConfig: PromptConfig
|
||||
onStartChat: (inputs: Record<string, any>) => void
|
||||
canEditInputs: boolean
|
||||
savedInputs: Record<string, any>
|
||||
onInputsChange: (inputs: Record<string, any>) => void
|
||||
plan: string
|
||||
}
|
||||
|
||||
const Welcome: FC<IWelcomeProps> = ({
|
||||
// conversationName,
|
||||
hasSetInputs,
|
||||
isPublicVersion,
|
||||
siteInfo,
|
||||
plan,
|
||||
promptConfig,
|
||||
onStartChat,
|
||||
canEditInputs,
|
||||
savedInputs,
|
||||
onInputsChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const hasVar = promptConfig.prompt_variables.length > 0
|
||||
const [isFold, setIsFold] = useState<boolean>(true)
|
||||
const [inputs, setInputs] = useState<Record<string, any>>((() => {
|
||||
if (hasSetInputs)
|
||||
return savedInputs
|
||||
|
||||
const res: Record<string, any> = {}
|
||||
if (promptConfig) {
|
||||
promptConfig.prompt_variables.forEach((item) => {
|
||||
res[item.key] = ''
|
||||
})
|
||||
}
|
||||
// debugger
|
||||
return res
|
||||
})())
|
||||
useEffect(() => {
|
||||
if (!savedInputs) {
|
||||
const res: Record<string, any> = {}
|
||||
if (promptConfig) {
|
||||
promptConfig.prompt_variables.forEach((item) => {
|
||||
res[item.key] = ''
|
||||
})
|
||||
}
|
||||
setInputs(res)
|
||||
}
|
||||
else {
|
||||
setInputs(savedInputs)
|
||||
}
|
||||
}, [savedInputs])
|
||||
|
||||
const highLightPromoptTemplate = (() => {
|
||||
if (!promptConfig)
|
||||
return ''
|
||||
const res = promptConfig.prompt_template.replace(regex, (match, p1) => {
|
||||
return `<span class='text-gray-800 font-bold'>${inputs?.[p1] ? inputs?.[p1] : match}</span>`
|
||||
})
|
||||
return res
|
||||
})()
|
||||
|
||||
const { notify } = useContext(ToastContext)
|
||||
const logError = (message: string) => {
|
||||
notify({ type: 'error', message, duration: 3000 })
|
||||
}
|
||||
|
||||
// const renderHeader = () => {
|
||||
// return (
|
||||
// <div className='absolute top-0 left-0 right-0 flex items-center justify-between border-b border-gray-100 mobile:h-12 tablet:h-16 px-8 bg-white'>
|
||||
// <div className='text-gray-900'>{conversationName}</div>
|
||||
// </div>
|
||||
// )
|
||||
// }
|
||||
|
||||
const renderInputs = () => {
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{promptConfig.prompt_variables.map(item => (
|
||||
<div className='tablet:flex tablet:!h-9 mobile:space-y-2 tablet:space-y-0 mobile:text-xs tablet:text-sm' key={item.key}>
|
||||
<label className={`flex-shrink-0 flex items-center mobile:text-gray-700 tablet:text-gray-900 mobile:font-medium pc:font-normal ${s.formLabel}`}>{item.name}</label>
|
||||
{item.type === 'select'
|
||||
? (
|
||||
<Select
|
||||
className='w-full'
|
||||
defaultValue={inputs?.[item.key]}
|
||||
onSelect={(i) => { setInputs({ ...inputs, [item.key]: i.value }) }}
|
||||
items={(item.options || []).map(i => ({ name: i, value: i }))}
|
||||
allowSearch={false}
|
||||
bgClassName='bg-gray-50'
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<input
|
||||
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
|
||||
value={inputs?.[item.key] || ''}
|
||||
onChange={(e) => { setInputs({ ...inputs, [item.key]: e.target.value }) }}
|
||||
className={'w-full flex-grow py-2 pl-3 pr-3 box-border rounded-lg bg-gray-50'}
|
||||
maxLength={item.max_length || DEFAULT_VALUE_MAX_LEN}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const canChat = () => {
|
||||
const prompt_variables = promptConfig?.prompt_variables
|
||||
if (!inputs || !prompt_variables || prompt_variables?.length === 0)
|
||||
return true
|
||||
|
||||
let hasEmptyInput = false
|
||||
const requiredVars = prompt_variables?.filter(({ key, name, required }) => {
|
||||
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
|
||||
return res
|
||||
}) || [] // compatible with old version
|
||||
requiredVars.forEach(({ key }) => {
|
||||
if (hasEmptyInput)
|
||||
return
|
||||
|
||||
if (!inputs?.[key])
|
||||
hasEmptyInput = true
|
||||
})
|
||||
|
||||
if (hasEmptyInput) {
|
||||
logError(t('appDebug.errorMessage.valueOfVarRequired'))
|
||||
return false
|
||||
}
|
||||
return !hasEmptyInput
|
||||
}
|
||||
|
||||
const handleChat = () => {
|
||||
if (!canChat())
|
||||
return
|
||||
|
||||
onStartChat(inputs)
|
||||
}
|
||||
|
||||
const renderNoVarPanel = () => {
|
||||
if (isPublicVersion) {
|
||||
return (
|
||||
<div>
|
||||
<AppInfo siteInfo={siteInfo} />
|
||||
<TemplateVarPanel
|
||||
isFold={false}
|
||||
header={
|
||||
<>
|
||||
<PanelTitle
|
||||
title={t('share.chat.publicPromptConfigTitle')}
|
||||
className='mb-1'
|
||||
/>
|
||||
<PromptTemplate html={highLightPromoptTemplate} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ChatBtn onClick={handleChat} />
|
||||
</TemplateVarPanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// private version
|
||||
return (
|
||||
<TemplateVarPanel
|
||||
isFold={false}
|
||||
header={
|
||||
<AppInfo siteInfo={siteInfo} />
|
||||
}
|
||||
>
|
||||
<ChatBtn onClick={handleChat} />
|
||||
</TemplateVarPanel>
|
||||
)
|
||||
}
|
||||
|
||||
const renderVarPanel = () => {
|
||||
return (
|
||||
<TemplateVarPanel
|
||||
isFold={false}
|
||||
header={
|
||||
<AppInfo siteInfo={siteInfo} />
|
||||
}
|
||||
>
|
||||
{renderInputs()}
|
||||
<ChatBtn
|
||||
className='mt-3 mobile:ml-0 tablet:ml-[128px]'
|
||||
onClick={handleChat}
|
||||
/>
|
||||
</TemplateVarPanel>
|
||||
)
|
||||
}
|
||||
|
||||
const renderVarOpBtnGroup = () => {
|
||||
return (
|
||||
<VarOpBtnGroup
|
||||
onConfirm={() => {
|
||||
if (!canChat())
|
||||
return
|
||||
|
||||
onInputsChange(inputs)
|
||||
setIsFold(true)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setInputs(savedInputs)
|
||||
setIsFold(true)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderHasSetInputsPublic = () => {
|
||||
if (!canEditInputs) {
|
||||
return (
|
||||
<TemplateVarPanel
|
||||
isFold={false}
|
||||
header={
|
||||
<>
|
||||
<PanelTitle
|
||||
title={t('share.chat.publicPromptConfigTitle')}
|
||||
className='mb-1'
|
||||
/>
|
||||
<PromptTemplate html={highLightPromoptTemplate} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TemplateVarPanel
|
||||
isFold={isFold}
|
||||
header={
|
||||
<>
|
||||
<PanelTitle
|
||||
title={t('share.chat.publicPromptConfigTitle')}
|
||||
className='mb-1'
|
||||
/>
|
||||
<PromptTemplate html={highLightPromoptTemplate} />
|
||||
{isFold && (
|
||||
<div className='flex items-center justify-between mt-3 border-t border-indigo-100 pt-4 text-xs text-indigo-600'>
|
||||
<span className='text-gray-700'>{t('share.chat.configStatusDes')}</span>
|
||||
<EditBtn onClick={() => setIsFold(false)} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{renderInputs()}
|
||||
{renderVarOpBtnGroup()}
|
||||
</TemplateVarPanel>
|
||||
)
|
||||
}
|
||||
|
||||
const renderHasSetInputsPrivate = () => {
|
||||
if (!canEditInputs || !hasVar)
|
||||
return null
|
||||
|
||||
return (
|
||||
<TemplateVarPanel
|
||||
isFold={isFold}
|
||||
header={
|
||||
<div className='flex items-center justify-between text-indigo-600'>
|
||||
<PanelTitle
|
||||
title={!isFold ? t('share.chat.privatePromptConfigTitle') : t('share.chat.configStatusDes')}
|
||||
/>
|
||||
{isFold && (
|
||||
<EditBtn onClick={() => setIsFold(false)} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{renderInputs()}
|
||||
{renderVarOpBtnGroup()}
|
||||
</TemplateVarPanel>
|
||||
)
|
||||
}
|
||||
|
||||
const renderHasSetInputs = () => {
|
||||
if ((!isPublicVersion && !canEditInputs) || !hasVar)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className='pt-[88px] mb-5'
|
||||
>
|
||||
{isPublicVersion ? renderHasSetInputsPublic() : renderHasSetInputsPrivate()}
|
||||
</div>)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative mobile:min-h-[48px] tablet:min-h-[64px]'>
|
||||
{/* {hasSetInputs && renderHeader()} */}
|
||||
<div className='mx-auto pc:w-[794px] max-w-full mobile:w-full px-3.5'>
|
||||
{/* Has't set inputs */}
|
||||
{
|
||||
!hasSetInputs && (
|
||||
<div className='mobile:pt-[72px] tablet:pt-[128px] pc:pt-[200px]'>
|
||||
{hasVar
|
||||
? (
|
||||
renderVarPanel()
|
||||
)
|
||||
: (
|
||||
renderNoVarPanel()
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Has set inputs */}
|
||||
{hasSetInputs && renderHasSetInputs()}
|
||||
|
||||
{/* foot */}
|
||||
{!hasSetInputs && (
|
||||
<div className='mt-4 flex justify-between items-center h-8 text-xs text-gray-400'>
|
||||
|
||||
{siteInfo.privacy_policy
|
||||
? <div>{t('share.chat.privacyPolicyLeft')}
|
||||
<a
|
||||
className='text-gray-500'
|
||||
href={siteInfo.privacy_policy}
|
||||
target='_blank'>{t('share.chat.privacyPolicyMiddle')}</a>
|
||||
{t('share.chat.privacyPolicyRight')}
|
||||
</div>
|
||||
: <div>
|
||||
</div>}
|
||||
{plan === 'basic' && <a className='flex items-center pr-3 space-x-3' href="https://dify.ai/" target="_blank">
|
||||
<span className='uppercase'>{t('share.chat.powerBy')}</span>
|
||||
<FootLogo />
|
||||
</a>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Welcome)
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import cn from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PencilIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import s from './style.module.css'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
export const AppInfo: FC<{ siteInfo: SiteInfo }> = ({ siteInfo }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div>
|
||||
<div className='flex items-center py-2 text-xl font-medium text-gray-700 rounded-md'>👏 {t('share.common.welcome')} {siteInfo.title}</div>
|
||||
<p className='text-sm text-gray-500'>{siteInfo.description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PromptTemplate: FC<{ html: string }> = ({ html }) => {
|
||||
return (
|
||||
<div
|
||||
className={' box-border text-sm text-gray-700'}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
></div>
|
||||
)
|
||||
}
|
||||
|
||||
export const StarIcon = () => (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.75 1C2.75 0.723858 2.52614 0.5 2.25 0.5C1.97386 0.5 1.75 0.723858 1.75 1V1.75H1C0.723858 1.75 0.5 1.97386 0.5 2.25C0.5 2.52614 0.723858 2.75 1 2.75H1.75V3.5C1.75 3.77614 1.97386 4 2.25 4C2.52614 4 2.75 3.77614 2.75 3.5V2.75H3.5C3.77614 2.75 4 2.52614 4 2.25C4 1.97386 3.77614 1.75 3.5 1.75H2.75V1Z" fill="#444CE7" />
|
||||
<path d="M2.75 8.5C2.75 8.22386 2.52614 8 2.25 8C1.97386 8 1.75 8.22386 1.75 8.5V9.25H1C0.723858 9.25 0.5 9.47386 0.5 9.75C0.5 10.0261 0.723858 10.25 1 10.25H1.75V11C1.75 11.2761 1.97386 11.5 2.25 11.5C2.52614 11.5 2.75 11.2761 2.75 11V10.25H3.5C3.77614 10.25 4 10.0261 4 9.75C4 9.47386 3.77614 9.25 3.5 9.25H2.75V8.5Z" fill="#444CE7" />
|
||||
<path d="M6.96667 1.32051C6.8924 1.12741 6.70689 1 6.5 1C6.29311 1 6.10759 1.12741 6.03333 1.32051L5.16624 3.57494C5.01604 3.96546 4.96884 4.078 4.90428 4.1688C4.8395 4.2599 4.7599 4.3395 4.6688 4.40428C4.578 4.46884 4.46546 4.51604 4.07494 4.66624L1.82051 5.53333C1.62741 5.60759 1.5 5.79311 1.5 6C1.5 6.20689 1.62741 6.39241 1.82051 6.46667L4.07494 7.33376C4.46546 7.48396 4.578 7.53116 4.6688 7.59572C4.7599 7.6605 4.8395 7.7401 4.90428 7.8312C4.96884 7.922 5.01604 8.03454 5.16624 8.42506L6.03333 10.6795C6.1076 10.8726 6.29311 11 6.5 11C6.70689 11 6.89241 10.8726 6.96667 10.6795L7.83376 8.42506C7.98396 8.03454 8.03116 7.922 8.09572 7.8312C8.1605 7.7401 8.2401 7.6605 8.3312 7.59572C8.422 7.53116 8.53454 7.48396 8.92506 7.33376L11.1795 6.46667C11.3726 6.39241 11.5 6.20689 11.5 6C11.5 5.79311 11.3726 5.60759 11.1795 5.53333L8.92506 4.66624C8.53454 4.51604 8.422 4.46884 8.3312 4.40428C8.2401 4.3395 8.1605 4.2599 8.09572 4.1688C8.03116 4.078 7.98396 3.96546 7.83376 3.57494L6.96667 1.32051Z" fill="#444CE7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ChatBtn: FC<{ onClick: () => void; className?: string }> = ({
|
||||
className,
|
||||
onClick,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button
|
||||
type='primary'
|
||||
className={cn(className, `!p-0 space-x-2 flex items-center ${s.customBtn}`)}
|
||||
onClick={onClick}>
|
||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M18 10.5C18 14.366 14.418 17.5 10 17.5C8.58005 17.506 7.17955 17.1698 5.917 16.52L2 17.5L3.338 14.377C2.493 13.267 2 11.934 2 10.5C2 6.634 5.582 3.5 10 3.5C14.418 3.5 18 6.634 18 10.5ZM7 9.5H5V11.5H7V9.5ZM15 9.5H13V11.5H15V9.5ZM9 9.5H11V11.5H9V9.5Z" fill="white" />
|
||||
</svg>
|
||||
{t('share.chat.startChat')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export const EditBtn = ({ className, onClick }: { className?: string; onClick: () => void }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('px-2 flex space-x-1 items-center rounded-md cursor-pointer', className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<PencilIcon className='w-3 h-3' />
|
||||
<span>{t('common.operation.edit')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FootLogo = () => (
|
||||
<div className={s.logo} />
|
||||
)
|
||||
29
web/app/components/share/chatbot/welcome/style.module.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.boxShodow {
|
||||
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||
}
|
||||
|
||||
.bgGrayColor {
|
||||
background-color: #F9FAFB;
|
||||
}
|
||||
|
||||
.headerBg {
|
||||
height: 3.5rem;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
width: 120px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.customBtn {
|
||||
width: 136px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 48px;
|
||||
height: 20px;
|
||||
background: url(./icons/logo.png) center center no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
@@ -1,48 +1,42 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import {
|
||||
Bars3Icon,
|
||||
PencilSquareIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
export type IHeaderProps = {
|
||||
title: string
|
||||
icon: string
|
||||
icon_background: string
|
||||
isMobile?: boolean
|
||||
onShowSideBar?: () => void
|
||||
onCreateNewChat?: () => void
|
||||
isEmbedScene?: boolean
|
||||
}
|
||||
const Header: FC<IHeaderProps> = ({
|
||||
title,
|
||||
isMobile,
|
||||
icon,
|
||||
icon_background,
|
||||
onShowSideBar,
|
||||
onCreateNewChat,
|
||||
isEmbedScene = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className="shrink-0 flex items-center justify-between h-12 px-3 bg-gray-100">
|
||||
{isMobile ? (
|
||||
<div
|
||||
className='flex items-center justify-center h-8 w-8 cursor-pointer'
|
||||
onClick={() => onShowSideBar?.()}
|
||||
>
|
||||
<Bars3Icon className="h-4 w-4 text-gray-500" />
|
||||
return !isMobile
|
||||
? null
|
||||
: (
|
||||
<div
|
||||
className={`shrink-0 flex items-center justify-between h-12 px-3 bg-gray-100 ${
|
||||
isEmbedScene ? 'bg-gradient-to-r from-blue-600 to-sky-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div></div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<AppIcon size="small" icon={icon} background={icon_background} />
|
||||
<div
|
||||
className={`text-sm text-gray-800 font-bold ${
|
||||
isEmbedScene ? 'text-white' : ''
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
) : <div></div>}
|
||||
<div className='flex items-center space-x-2'>
|
||||
<AppIcon size="small" icon={icon} background={icon_background} />
|
||||
<div className=" text-sm text-gray-800 font-bold">{title}</div>
|
||||
<div></div>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<div className='flex items-center justify-center h-8 w-8 cursor-pointer'
|
||||
onClick={() => onCreateNewChat?.()}
|
||||
>
|
||||
<PencilSquareIcon className="h-4 w-4 text-gray-500" />
|
||||
</div>) : <div></div>}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Header)
|
||||
|
||||
@@ -7,8 +7,10 @@ import { useBoolean, useClickAway, useGetState } from 'ahooks'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import TabHeader from '../../base/tab-header'
|
||||
import Button from '../../base/button'
|
||||
import { checkOrSetAccessToken } from '../utils'
|
||||
import s from './style.module.css'
|
||||
import RunBatch from './run-batch'
|
||||
import ResDownload from './run-batch/res-download'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import RunOnce from '@/app/components/share/text-generation/run-once'
|
||||
import { fetchSavedMessage as doFetchSavedMessage, fetchAppInfo, fetchAppParams, removeMessage, saveMessage } from '@/service/share'
|
||||
@@ -23,7 +25,6 @@ import SavedItems from '@/app/components/app/text-generate/saved-items'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import { appDefaultIconBackground } from '@/config'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
const PARALLEL_LIMIT = 5
|
||||
enum TaskStatus {
|
||||
pending = 'pending',
|
||||
@@ -76,9 +77,6 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
const res: any = await doFetchSavedMessage(isInstalledApp, installedAppInfo?.id)
|
||||
setSavedMessages(res.data)
|
||||
}
|
||||
useEffect(() => {
|
||||
fetchSavedMessage()
|
||||
}, [])
|
||||
const handleSaveMessage = async (messageId: string) => {
|
||||
await saveMessage(messageId, isInstalledApp, installedAppInfo?.id)
|
||||
notify({ type: 'success', message: t('common.api.saved') })
|
||||
@@ -98,6 +96,8 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
setControlSend(Date.now())
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
setAllTaskList([]) // clear batch task running status
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
showResSidebar()
|
||||
}
|
||||
|
||||
const [allTaskList, setAllTaskList, getLatestTaskList] = useGetState<Task[]>([])
|
||||
@@ -105,6 +105,20 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
const noPendingTask = pendingTaskList.length === 0
|
||||
const showTaskList = allTaskList.filter(task => task.status !== TaskStatus.pending)
|
||||
const allTaskFinished = allTaskList.every(task => task.status === TaskStatus.completed)
|
||||
const [batchCompletionRes, setBatchCompletionRes, getBatchCompletionRes] = useGetState<Record<string, string>>({})
|
||||
const exportRes = allTaskList.map((task) => {
|
||||
if (allTaskList.length > 0 && !allTaskFinished)
|
||||
return {}
|
||||
const batchCompletionResLatest = getBatchCompletionRes()
|
||||
const res: Record<string, string> = {}
|
||||
const { inputs, query } = task.params
|
||||
promptConfig?.prompt_variables.forEach((v) => {
|
||||
res[v.name] = inputs[v.key]
|
||||
})
|
||||
res[t('share.generation.queryTitle')] = query
|
||||
res[t('share.generation.completionResult')] = batchCompletionResLatest[task.id]
|
||||
return res
|
||||
})
|
||||
const checkBatchInputs = (data: string[][]) => {
|
||||
if (!data || data.length === 0) {
|
||||
notify({ type: 'error', message: t('share.generation.errorMsg.empty') })
|
||||
@@ -229,11 +243,12 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
setControlSend(Date.now())
|
||||
// clear run once task status
|
||||
setControlStopResponding(Date.now())
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
showResSidebar()
|
||||
}
|
||||
|
||||
const handleCompleted = (taskId?: number, isSuccess?: boolean) => {
|
||||
// console.log(taskId, isSuccess)
|
||||
const handleCompleted = (completionRes: string, taskId?: number) => {
|
||||
const allTasklistLatest = getLatestTaskList()
|
||||
const batchCompletionResLatest = getBatchCompletionRes()
|
||||
const pendingTaskList = allTasklistLatest.filter(task => task.status === TaskStatus.pending)
|
||||
const nextPendingTaskId = pendingTaskList[0]?.id
|
||||
// console.log(`start: ${allTasklistLatest.map(item => item.status).join(',')}`)
|
||||
@@ -254,9 +269,18 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
})
|
||||
// console.log(`end: ${newAllTaskList.map(item => item.status).join(',')}`)
|
||||
setAllTaskList(newAllTaskList)
|
||||
if (taskId) {
|
||||
setBatchCompletionRes({
|
||||
...batchCompletionResLatest,
|
||||
[`${taskId}`]: completionRes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const fetchInitData = () => {
|
||||
const fetchInitData = async () => {
|
||||
if (!isInstalledApp)
|
||||
await checkOrSetAccessToken()
|
||||
|
||||
return Promise.all([isInstalledApp
|
||||
? {
|
||||
app_id: installedAppInfo?.id,
|
||||
@@ -267,7 +291,7 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
},
|
||||
plan: 'basic',
|
||||
}
|
||||
: fetchAppInfo(), fetchAppParams(isInstalledApp, installedAppInfo?.id)])
|
||||
: fetchAppInfo(), fetchAppParams(isInstalledApp, installedAppInfo?.id), fetchSavedMessage()])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -339,14 +363,23 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
<div className={s.starIcon}></div>
|
||||
<div className='text-lg text-gray-800 font-semibold'>{t('share.generation.title')}</div>
|
||||
</div>
|
||||
{!isPC && (
|
||||
<div
|
||||
className='flex items-center justify-center cursor-pointer'
|
||||
onClick={hideResSidebar}
|
||||
>
|
||||
<XMarkIcon className='w-4 h-4 text-gray-800' />
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center space-x-2'>
|
||||
{allTaskList.length > 0 && allTaskFinished && (
|
||||
<ResDownload
|
||||
isMobile={isMobile}
|
||||
values={exportRes}
|
||||
/>
|
||||
)}
|
||||
{!isPC && (
|
||||
<div
|
||||
className='flex items-center justify-center cursor-pointer'
|
||||
onClick={hideResSidebar}
|
||||
>
|
||||
<XMarkIcon className='w-4 h-4 text-gray-800' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className='grow overflow-y-auto'>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useBoolean, useGetState } from 'ahooks'
|
||||
import { t } from 'i18next'
|
||||
import cn from 'classnames'
|
||||
import TextGenerationRes from '@/app/components/app/text-generate/item'
|
||||
@@ -27,7 +27,7 @@ export type IResultProps = {
|
||||
onShowRes: () => void
|
||||
handleSaveMessage: (messageId: string) => void
|
||||
taskId?: number
|
||||
onCompleted: (taskId?: number, success?: boolean) => void
|
||||
onCompleted: (completionRes: string, taskId?: number, success?: boolean) => void
|
||||
}
|
||||
|
||||
const Result: FC<IResultProps> = ({
|
||||
@@ -53,7 +53,7 @@ const Result: FC<IResultProps> = ({
|
||||
setResponsingFalse()
|
||||
}, [controlStopResponding])
|
||||
|
||||
const [completionRes, setCompletionRes] = useState('')
|
||||
const [completionRes, setCompletionRes, getCompletionRes] = useGetState('')
|
||||
const { notify } = Toast
|
||||
const isNoData = !completionRes
|
||||
|
||||
@@ -141,11 +141,11 @@ const Result: FC<IResultProps> = ({
|
||||
onCompleted: () => {
|
||||
setResponsingFalse()
|
||||
setMessageId(tempMessageId)
|
||||
onCompleted(taskId, true)
|
||||
onCompleted(getCompletionRes(), taskId, true)
|
||||
},
|
||||
onError() {
|
||||
setResponsingFalse()
|
||||
onCompleted(taskId, false)
|
||||
onCompleted(getCompletionRes(), taskId, false)
|
||||
},
|
||||
}, isInstalledApp, installedAppInfo?.id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import {
|
||||
useCSVDownloader,
|
||||
} from 'react-papaparse'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from 'classnames'
|
||||
import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import Button from '@/app/components/base/button'
|
||||
export type IResDownloadProps = {
|
||||
isMobile: boolean
|
||||
values: Record<string, string>[]
|
||||
}
|
||||
|
||||
const ResDownload: FC<IResDownloadProps> = ({
|
||||
isMobile,
|
||||
values,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { CSVDownloader, Type } = useCSVDownloader()
|
||||
|
||||
return (
|
||||
<CSVDownloader
|
||||
className="block cursor-pointer"
|
||||
type={Type.Link}
|
||||
filename={'result'}
|
||||
bom={true}
|
||||
config={{
|
||||
// delimiter: ';',
|
||||
}}
|
||||
data={values}
|
||||
>
|
||||
<Button className={cn('flex items-center !h-8 space-x-2 bg-white !text-[13px] font-medium', isMobile ? '!p-0 !w-8 justify-center' : '!px-3')}>
|
||||
<DownloadIcon className='w-4 h-4 text-[#155EEF]' />
|
||||
{!isMobile && <span className='text-[#155EEF]'>{t('common.operation.download')}</span>}
|
||||
</Button>
|
||||
</CSVDownloader>
|
||||
)
|
||||
}
|
||||
export default React.memo(ResDownload)
|
||||
18
web/app/components/share/utils.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { fetchAccessToken } from '@/service/share'
|
||||
|
||||
export const checkOrSetAccessToken = async () => {
|
||||
const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0]
|
||||
const accessToken = localStorage.getItem('token') || JSON.stringify({ [sharedToken]: '' })
|
||||
let accessTokenJson = { [sharedToken]: '' }
|
||||
try {
|
||||
accessTokenJson = JSON.parse(accessToken)
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
}
|
||||
if (!accessTokenJson[sharedToken]) {
|
||||
const res = await fetchAccessToken(sharedToken)
|
||||
accessTokenJson[sharedToken] = res.access_token
|
||||
localStorage.setItem('token', JSON.stringify(accessTokenJson))
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Toast from '../components/base/toast'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { setup } from '@/service/common'
|
||||
|
||||
const validEmailReg = /^[\w\.-]+@([\w-]+\.)+[\w-]{2,}$/
|
||||
@@ -40,36 +40,37 @@ const InstallForm = () => {
|
||||
showErrorMessage(t('login.error.passwordEmpty'))
|
||||
return false
|
||||
}
|
||||
if (!validPassword.test(password)) {
|
||||
if (!validPassword.test(password))
|
||||
showErrorMessage(t('login.error.passwordInvalid'))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
const handleSetting = async () => {
|
||||
if (!valid()) return
|
||||
if (!valid())
|
||||
return
|
||||
await setup({
|
||||
body: {
|
||||
email,
|
||||
name,
|
||||
password
|
||||
}
|
||||
password,
|
||||
},
|
||||
})
|
||||
router.push('/signin')
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="text-3xl font-normal text-gray-900">{t('login.setAdminAccount')}</h2>
|
||||
<h2 className="text-[32px] font-bold text-gray-900">{t('login.setAdminAccount')}</h2>
|
||||
<p className='
|
||||
mt-2 text-sm text-gray-600
|
||||
mt-1 text-sm text-gray-600
|
||||
'>{t('login.setAdminAccountDesc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grow mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white ">
|
||||
<form className="space-y-6" onSubmit={() => { }}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
<form onSubmit={() => { }}>
|
||||
<div className='mb-5'>
|
||||
<label htmlFor="email" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
{t('login.email')}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
@@ -78,13 +79,14 @@ const InstallForm = () => {
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className={'appearance-none block w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 rounded-md shadow-sm placeholder-gray-400 sm:text-sm'}
|
||||
placeholder={t('login.emailPlaceholder') || ''}
|
||||
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
<div className='mb-5'>
|
||||
<label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
{t('login.name')}
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
@@ -93,13 +95,14 @@ const InstallForm = () => {
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className={'appearance-none block w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 rounded-md shadow-sm placeholder-gray-400 sm:text-sm pr-10'}
|
||||
placeholder={t('login.namePlaceholder') || ''}
|
||||
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
|
||||
<div className='mb-5'>
|
||||
<label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
{t('login.password')}
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
@@ -108,7 +111,8 @@ const InstallForm = () => {
|
||||
type='password'
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className={'appearance-none block w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 rounded-md shadow-sm placeholder-gray-400 sm:text-sm pr-10'}
|
||||
placeholder={t('login.passwordPlaceholder') || ''}
|
||||
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-1 text-xs text-gray-500'>{t('login.error.passwordInvalid')}</div>
|
||||
@@ -123,29 +127,21 @@ const InstallForm = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
{/* agree to our Terms and Privacy Policy. */}
|
||||
<div className="block mt-6 text-xs text-gray-600">
|
||||
{t('login.tosDesc')}
|
||||
|
||||
<Link
|
||||
className='text-primary-600'
|
||||
target={'_blank'}
|
||||
href='https://docs.dify.ai/user-agreement/terms-of-service'
|
||||
>{t('login.tos')}</Link>
|
||||
&
|
||||
<Link
|
||||
className='text-primary-600'
|
||||
target={'_blank'}
|
||||
href='https://langgenius.ai/privacy-policy'
|
||||
>{t('login.pp')}</Link>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button type='primary' onClick={handleSetting}>
|
||||
<Button type='primary' className='w-full !fone-medium !text-sm' onClick={handleSetting}>
|
||||
{t('login.installBtn')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="block w-hull mt-2 text-xs text-gray-600">
|
||||
{t('login.license.tip')}
|
||||
|
||||
<Link
|
||||
className='text-primary-600'
|
||||
target={'_blank'}
|
||||
href='https://docs.dify.ai/community/open-source'
|
||||
>{t('login.license.link')}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import style from './page.module.css'
|
||||
import Select, { LOCALES } from '@/app/components/base/select/locale'
|
||||
import { type Locale } from '@/i18n'
|
||||
import I18n from '@/context/i18n'
|
||||
import { setLocaleOnClient } from '@/i18n/client'
|
||||
import { useContext } from 'use-context-selector'
|
||||
|
||||
|
||||
type IHeaderProps = {
|
||||
locale: string
|
||||
}
|
||||
|
||||
const Header = () => {
|
||||
const { locale, setLocaleOnClient } = useContext(I18n)
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import React from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
|
||||
import cn from 'classnames'
|
||||
import NormalForm from './normalForm'
|
||||
import OneMoreStep from './oneMoreStep'
|
||||
import classNames from 'classnames'
|
||||
|
||||
const Forms = () => {
|
||||
const searchParams = useSearchParams()
|
||||
@@ -19,7 +19,7 @@ const Forms = () => {
|
||||
}
|
||||
}
|
||||
return <div className={
|
||||
classNames(
|
||||
cn(
|
||||
'flex flex-col items-center w-full grow items-center justify-center',
|
||||
'px-6',
|
||||
'md:px-[108px]',
|
||||
@@ -28,7 +28,6 @@ const Forms = () => {
|
||||
<div className='flex flex-col md:w-[400px]'>
|
||||
{getForm()}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
import React, { useEffect, useReducer, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import classNames from 'classnames'
|
||||
import useSWR from 'swr'
|
||||
import Link from 'next/link'
|
||||
import Toast from '../components/base/toast'
|
||||
import style from './page.module.css'
|
||||
// import Tooltip from '@/app/components/base/tooltip/index'
|
||||
import Toast from '../components/base/toast'
|
||||
import { IS_CE_EDITION, apiPrefix } from '@/config'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { login, oauth } from '@/service/common'
|
||||
import { apiPrefix } from '@/config'
|
||||
|
||||
const validEmailReg = /^[\w\.-]+@([\w-]+\.)+[\w-]{2,}$/
|
||||
|
||||
@@ -91,8 +90,9 @@ const NormalForm = () => {
|
||||
remember_me: true,
|
||||
},
|
||||
})
|
||||
router.push('/')
|
||||
} finally {
|
||||
router.push('/apps')
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -132,8 +132,8 @@ const NormalForm = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full mx-auto">
|
||||
<h2 className="text-3xl font-normal text-gray-900">{t('login.pageTitle')}</h2>
|
||||
<p className='mt-2 text-sm text-gray-600 '>{t('login.welcome')}</p>
|
||||
<h2 className="text-[32px] font-bold text-gray-900">{t('login.pageTitle')}</h2>
|
||||
<p className='mt-1 text-sm text-gray-600'>{t('login.welcome')}</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full mx-auto mt-8">
|
||||
@@ -145,7 +145,7 @@ const NormalForm = () => {
|
||||
<Button
|
||||
type='default'
|
||||
disabled={isLoading}
|
||||
className='w-full'
|
||||
className='w-full hover:!bg-gray-50 !text-sm !font-medium'
|
||||
>
|
||||
<>
|
||||
<span className={
|
||||
@@ -154,7 +154,7 @@ const NormalForm = () => {
|
||||
'w-5 h-5 mr-2',
|
||||
)
|
||||
} />
|
||||
<span className="truncate">{t('login.withGitHub')}</span>
|
||||
<span className="truncate text-gray-800">{t('login.withGitHub')}</span>
|
||||
</>
|
||||
</Button>
|
||||
</a>
|
||||
@@ -164,7 +164,7 @@ const NormalForm = () => {
|
||||
<Button
|
||||
type='default'
|
||||
disabled={isLoading}
|
||||
className='w-full'
|
||||
className='w-full hover:!bg-gray-50 !text-sm !font-medium'
|
||||
>
|
||||
<>
|
||||
<span className={
|
||||
@@ -173,7 +173,7 @@ const NormalForm = () => {
|
||||
'w-5 h-5 mr-2',
|
||||
)
|
||||
} />
|
||||
<span className="truncate">{t('login.withGoogle')}</span>
|
||||
<span className="truncate text-gray-800">{t('login.withGoogle')}</span>
|
||||
</>
|
||||
</Button>
|
||||
</a>
|
||||
@@ -192,9 +192,9 @@ const NormalForm = () => {
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<form className="space-y-6" onSubmit={() => { }}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
<form onSubmit={() => { }}>
|
||||
<div className='mb-5'>
|
||||
<label htmlFor="email" className="my-2 block text-sm font-medium text-gray-900">
|
||||
{t('login.email')}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
@@ -204,13 +204,14 @@ const NormalForm = () => {
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={'appearance-none block w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 rounded-md shadow-sm placeholder-gray-400 sm:text-sm'}
|
||||
placeholder={t('login.emailPlaceholder') || ''}
|
||||
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="flex items-center justify-between text-sm font-medium text-gray-700">
|
||||
<div className='mb-4'>
|
||||
<label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
<span>{t('login.password')}</span>
|
||||
{/* <Tooltip
|
||||
selector='forget-password'
|
||||
@@ -235,10 +236,8 @@ const NormalForm = () => {
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
className={`appearance-none block w-full px-3 py-2
|
||||
border border-gray-300
|
||||
focus:outline-none focus:ring-indigo-500 focus:border-indigo-500
|
||||
rounded-md shadow-sm placeholder-gray-400 sm:text-sm pr-10`}
|
||||
placeholder={t('login.passwordPlaceholder') || ''}
|
||||
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<button
|
||||
@@ -252,18 +251,19 @@ const NormalForm = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='mb-2'>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={handleEmailPasswordLogin}
|
||||
disabled={isLoading}
|
||||
className="w-full !fone-medium !text-sm"
|
||||
>{t('login.signBtn')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
}
|
||||
{/* agree to our Terms and Privacy Policy. */}
|
||||
<div className="block mt-6 text-xs text-gray-600">
|
||||
<div className="w-hull text-center block mt-2 text-xs text-gray-600">
|
||||
{t('login.tosDesc')}
|
||||
|
||||
<Link
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import React, { useEffect, useReducer } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Link from 'next/link'
|
||||
import useSWR from 'swr'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Button from '@/app/components/base/button'
|
||||
@@ -74,14 +75,14 @@ const OneMoreStep = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full mx-auto">
|
||||
<h2 className="text-3xl font-normal text-gray-900">{t('login.oneMoreStep')}</h2>
|
||||
<p className='mt-2 text-sm text-gray-600 '>{t('login.createSample')}</p>
|
||||
<h2 className="text-[32px] font-bold text-gray-900">{t('login.oneMoreStep')}</h2>
|
||||
<p className='mt-1 text-sm text-gray-600 '>{t('login.createSample')}</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full mx-auto mt-8">
|
||||
<div className="space-y-6 bg-white">
|
||||
<div className="">
|
||||
<label className="flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
<div className="w-full mx-auto mt-6">
|
||||
<div className="bg-white">
|
||||
<div className="mb-5">
|
||||
<label className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
{t('login.invitationCode')}
|
||||
<Tooltip
|
||||
clickable
|
||||
@@ -103,16 +104,16 @@ const OneMoreStep = () => {
|
||||
id="invitation_code"
|
||||
value={state.invitation_code}
|
||||
type="text"
|
||||
className={'appearance-none block w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-primary-600 focus:border-primary-600 rounded-md shadow-sm placeholder-gray-400 sm:text-sm'}
|
||||
placeholder={t('login.invitationCodePlaceholder') || ''}
|
||||
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm'}
|
||||
onChange={(e) => {
|
||||
dispatch({ type: 'invitation_code', value: e.target.value.trim() })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
<div className='mb-5'>
|
||||
<label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
{t('login.interfaceLanguage')}
|
||||
</label>
|
||||
<div className="relative mt-1 rounded-md shadow-sm">
|
||||
@@ -125,8 +126,7 @@ const OneMoreStep = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<div className='mb-4'>
|
||||
<label htmlFor="timezone" className="block text-sm font-medium text-gray-700">
|
||||
{t('login.timezone')}
|
||||
</label>
|
||||
@@ -143,6 +143,7 @@ const OneMoreStep = () => {
|
||||
<div>
|
||||
<Button
|
||||
type='primary'
|
||||
className='w-full !fone-medium !text-sm'
|
||||
disabled={state.formState === 'processing'}
|
||||
onClick={() => {
|
||||
dispatch({ type: 'formState', value: 'processing' })
|
||||
@@ -151,6 +152,15 @@ const OneMoreStep = () => {
|
||||
{t('login.go')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="block w-hull mt-2 text-xs text-gray-600">
|
||||
{t('login.license.tip')}
|
||||
|
||||
<Link
|
||||
className='text-primary-600'
|
||||
target={'_blank'}
|
||||
href='https://docs.dify.ai/community/open-source'
|
||||
>{t('login.license.link')}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import React from 'react'
|
||||
import cn from 'classnames'
|
||||
import Forms from './forms'
|
||||
import Header from './_header'
|
||||
import style from './page.module.css'
|
||||
import classNames from 'classnames'
|
||||
|
||||
const SignIn = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames(
|
||||
<div className={cn(
|
||||
style.background,
|
||||
'flex w-full min-h-screen',
|
||||
'sm:p-4 lg:p-8',
|
||||
'gap-x-20',
|
||||
'justify-center lg:justify-start'
|
||||
'justify-center lg:justify-start',
|
||||
)}>
|
||||
<div className={
|
||||
classNames(
|
||||
cn(
|
||||
'flex w-full flex-col bg-white shadow rounded-2xl shrink-0',
|
||||
'space-between'
|
||||
'space-between',
|
||||
)
|
||||
}>
|
||||
<Header />
|
||||
|
||||
9
web/bin/uglify-embed.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const fs = require('node:fs')
|
||||
// https://www.npmjs.com/package/uglify-js
|
||||
const UglifyJS = require('uglify-js')
|
||||
|
||||
const { readFileSync, writeFileSync } = fs
|
||||
|
||||
writeFileSync('public/embed.min.js', UglifyJS.minify({
|
||||
'embed.js': readFileSync('public/embed.js', 'utf8'),
|
||||
}).code, 'utf8')
|
||||
@@ -37,6 +37,8 @@ const AppContext = createContext<AppContextValue>({
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
avatar: '',
|
||||
is_password_set: false,
|
||||
},
|
||||
mutateUserProfile: () => { },
|
||||
pageContainerRef: createRef(),
|
||||
|
||||
@@ -4,8 +4,19 @@ set -e
|
||||
|
||||
export NEXT_PUBLIC_DEPLOY_ENV=${DEPLOY_ENV}
|
||||
export NEXT_PUBLIC_EDITION=${EDITION}
|
||||
export NEXT_PUBLIC_API_PREFIX=${CONSOLE_URL}/console/api
|
||||
export NEXT_PUBLIC_PUBLIC_API_PREFIX=${APP_URL}/api
|
||||
|
||||
if [[ -z "$CONSOLE_URL" ]]; then
|
||||
export NEXT_PUBLIC_API_PREFIX=${CONSOLE_API_URL}/console/api
|
||||
else
|
||||
export NEXT_PUBLIC_API_PREFIX=${CONSOLE_URL}/console/api
|
||||
fi
|
||||
|
||||
if [[ -z "$APP_URL" ]]; then
|
||||
export NEXT_PUBLIC_PUBLIC_API_PREFIX=${APP_API_URL}/api
|
||||
else
|
||||
export NEXT_PUBLIC_PUBLIC_API_PREFIX=${APP_URL}/api
|
||||
fi
|
||||
|
||||
export NEXT_PUBLIC_SENTRY_DSN=${SENTRY_DSN}
|
||||
|
||||
/usr/local/bin/pm2 -v
|
||||
|
||||
1
web/global.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module 'lamejs';
|
||||
@@ -6,10 +6,10 @@ const translation = {
|
||||
addFeature: 'Add Feature',
|
||||
automatic: 'Automatic',
|
||||
stopResponding: 'Stop responding',
|
||||
agree: 'agree',
|
||||
disagree: 'disagree',
|
||||
cancelAgree: 'Cancel agree',
|
||||
cancelDisagree: 'Cancel disagree',
|
||||
agree: 'like',
|
||||
disagree: 'dislike',
|
||||
cancelAgree: 'Cancel like',
|
||||
cancelDisagree: 'Cancel dislike',
|
||||
userAction: 'User ',
|
||||
},
|
||||
notSetAPIKey: {
|
||||
|
||||
@@ -36,6 +36,15 @@ const translation = {
|
||||
privacyPolicyTip: 'Helps visitors understand the data the application collects, see Dify\'s <privacyPolicyLink>Privacy Policy</privacyPolicyLink>.',
|
||||
},
|
||||
},
|
||||
embedded: {
|
||||
entry: 'Embedded',
|
||||
title: 'Embed on website',
|
||||
explanation: 'Choose the way to embed chat app to your website',
|
||||
iframe: 'To add the chat app any where on your website, add this iframe to your html code.',
|
||||
scripts: 'To add a chat app to the bottom right of your website add this code to your html.',
|
||||
copied: 'Copied',
|
||||
copy: 'Copy',
|
||||
},
|
||||
customize: {
|
||||
way: 'way',
|
||||
entry: 'Want to customize your WebApp?',
|
||||
|
||||
@@ -36,6 +36,15 @@ const translation = {
|
||||
privacyPolicyTip: '帮助访问者了解该应用收集的数据,可参考 Dify 的<privacyPolicyLink>隐私政策</privacyPolicyLink>。',
|
||||
},
|
||||
},
|
||||
embedded: {
|
||||
entry: '嵌入',
|
||||
title: '嵌入到网站中',
|
||||
explanation: '选择一种方式将聊天应用嵌入到你的网站中',
|
||||
iframe: '将以下 iframe 嵌入到你的网站中的目标位置',
|
||||
scripts: '将以下代码嵌入到你的网站中',
|
||||
copied: '已复制',
|
||||
copy: '复制',
|
||||
},
|
||||
customize: {
|
||||
way: '方法',
|
||||
entry: '想要进一步自定义 WebApp?',
|
||||
|
||||
@@ -14,6 +14,7 @@ const translation = {
|
||||
edit: 'Edit',
|
||||
add: 'Add',
|
||||
refresh: 'Restart',
|
||||
reset: 'Reset',
|
||||
search: 'Search',
|
||||
change: 'Change',
|
||||
remove: 'Remove',
|
||||
@@ -21,6 +22,7 @@ const translation = {
|
||||
copy: 'Copy',
|
||||
lineBreak: 'Line break',
|
||||
sure: 'I\'m sure',
|
||||
download: 'Download',
|
||||
},
|
||||
placeholder: {
|
||||
input: 'Please enter',
|
||||
@@ -94,6 +96,14 @@ const translation = {
|
||||
avatar: 'Avatar',
|
||||
name: 'Name',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
passwordTip: 'You can set a permanent password if you don’t want to use temporary login codes',
|
||||
setPassword: 'Set a password',
|
||||
resetPassword: 'Reset password',
|
||||
currentPassword: 'Current password',
|
||||
newPassword: 'New password',
|
||||
confirmPassword: 'Confirm password',
|
||||
notEqual: 'Two passwords are different.',
|
||||
langGeniusAccount: 'Dify account',
|
||||
langGeniusAccountTip: 'Your Dify account and associated user data.',
|
||||
editName: 'Edit Name',
|
||||
@@ -110,15 +120,16 @@ const translation = {
|
||||
admin: 'Admin',
|
||||
adminTip: 'Can build apps & manage team settings',
|
||||
normal: 'Normal',
|
||||
normalTip: 'Only can use apps,can not build apps',
|
||||
normalTip: 'Only can use apps, can not build apps',
|
||||
inviteTeamMember: 'Add team member',
|
||||
inviteTeamMemberTip: 'They can access your team data directly after signing in.',
|
||||
email: 'Email',
|
||||
emailInvalid: 'Invalid Email Format',
|
||||
emailPlaceholder: 'Input Email',
|
||||
sendInvite: 'Add',
|
||||
invitationSent: 'Added',
|
||||
invitationSentTip: 'Added, and they can sign in to Dify to access your team data.',
|
||||
invitationSent: 'Invitation sent',
|
||||
invitationSentTip: 'Invitation sent, and they can sign in to Dify to access your team data.',
|
||||
invitationLink: 'Invitation Link',
|
||||
ok: 'OK',
|
||||
removeFromTeam: 'Remove from team',
|
||||
removeFromTeamTip: 'Will remove team access',
|
||||
|
||||
@@ -14,6 +14,7 @@ const translation = {
|
||||
edit: '编辑',
|
||||
add: '添加',
|
||||
refresh: '重新开始',
|
||||
reset: '重置',
|
||||
search: '搜索',
|
||||
change: '更改',
|
||||
remove: '移除',
|
||||
@@ -21,6 +22,7 @@ const translation = {
|
||||
copy: '复制',
|
||||
lineBreak: '换行',
|
||||
sure: '我确定',
|
||||
download: '下载',
|
||||
},
|
||||
placeholder: {
|
||||
input: '请输入',
|
||||
@@ -94,7 +96,14 @@ const translation = {
|
||||
avatar: '头像',
|
||||
name: '用户名',
|
||||
email: '邮箱',
|
||||
edit: '编辑',
|
||||
password: '密码',
|
||||
passwordTip: '如果您不想使用验证码登录,可以设置永久密码',
|
||||
setPassword: '设置密码',
|
||||
resetPassword: '重置密码',
|
||||
currentPassword: '原密码',
|
||||
newPassword: '新密码',
|
||||
notEqual: '两个密码不相同',
|
||||
confirmPassword: '确认密码',
|
||||
langGeniusAccount: 'Dify 账号',
|
||||
langGeniusAccountTip: '您的 Dify 账号和相关的用户数据。',
|
||||
editName: '编辑名字',
|
||||
@@ -118,8 +127,9 @@ const translation = {
|
||||
emailInvalid: '邮箱格式无效',
|
||||
emailPlaceholder: '输入邮箱',
|
||||
sendInvite: '添加',
|
||||
invitationSent: '已添加',
|
||||
invitationSentTip: '已添加,对方登录 Dify 后即可访问你的团队数据。',
|
||||
invitationSent: '邀请已发送',
|
||||
invitationSentTip: '邀请已发送,对方登录 Dify 后即可访问你的团队数据。',
|
||||
invitationLink: '邀请链接',
|
||||
ok: '好的',
|
||||
removeFromTeam: '移除团队',
|
||||
removeFromTeamTip: '将取消团队访问',
|
||||
|
||||
@@ -1,41 +1,57 @@
|
||||
const translation = {
|
||||
"pageTitle": "Hey, let's get started!👋",
|
||||
"welcome": "Welcome to Dify, please log in to continue.",
|
||||
"email": "Email address",
|
||||
"password": "Password",
|
||||
"name": "Name",
|
||||
"forget": "Forgot your password?",
|
||||
"signBtn": "Sign in",
|
||||
"installBtn": "Setting",
|
||||
"setAdminAccount": "Setting up an admin account",
|
||||
"setAdminAccountDesc": "Maximum privileges for admin account, which can be used to create applications and manage LLM providers, etc.",
|
||||
"createAndSignIn": "Create and sign in",
|
||||
"oneMoreStep": "One more step",
|
||||
"createSample": "Based on this information, we’ll create sample application for you",
|
||||
"invitationCode": "Invitation Code",
|
||||
"interfaceLanguage": "Interface Dify",
|
||||
"timezone": "Time zone",
|
||||
"go": "Go to Dify",
|
||||
"sendUsMail": "Email us your introduction, and we'll handle the invitation request.",
|
||||
"acceptPP": "I have read and accept the privacy policy",
|
||||
"reset": "Please run following command to reset your password",
|
||||
"withGitHub": "Continue with GitHub",
|
||||
"withGoogle": "Continue with Google",
|
||||
"rightTitle": "Unlock the full potential of LLM",
|
||||
"rightDesc": "Effortlessly build visually captivating, operable, and improvable AI applications.",
|
||||
"tos": "Terms of Service",
|
||||
"pp": "Privacy Policy",
|
||||
"tosDesc": "By signing up, you agree to our",
|
||||
"donthave": "Don't have?",
|
||||
"invalidInvitationCode": "Invalid invitation code",
|
||||
"accountAlreadyInited": "Account already inited",
|
||||
"error": {
|
||||
"emailEmpty": "Email address is required",
|
||||
"emailInValid": "Please enter a valid email address",
|
||||
"nameEmpty": "Name is required",
|
||||
"passwordEmpty": "Password is required",
|
||||
"passwordInvalid": "Password must contain letters and numbers, and the length must be greater than 8",
|
||||
}
|
||||
pageTitle: 'Hey, let\'s get started!👋',
|
||||
welcome: 'Welcome to Dify, please log in to continue.',
|
||||
email: 'Email address',
|
||||
emailPlaceholder: 'Your email',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: 'Your password',
|
||||
name: 'Username',
|
||||
namePlaceholder: 'Your username',
|
||||
forget: 'Forgot your password?',
|
||||
signBtn: 'Sign in',
|
||||
installBtn: 'Setting',
|
||||
setAdminAccount: 'Setting up an admin account',
|
||||
setAdminAccountDesc: 'Maximum privileges for admin account, which can be used to create applications and manage LLM providers, etc.',
|
||||
createAndSignIn: 'Create and sign in',
|
||||
oneMoreStep: 'One more step',
|
||||
createSample: 'Based on this information, we’ll create sample application for you',
|
||||
invitationCode: 'Invitation Code',
|
||||
invitationCodePlaceholder: 'Your invitation code',
|
||||
interfaceLanguage: 'Interface Language',
|
||||
timezone: 'Time zone',
|
||||
go: 'Go to Dify',
|
||||
sendUsMail: 'Email us your introduction, and we\'ll handle the invitation request.',
|
||||
acceptPP: 'I have read and accept the privacy policy',
|
||||
reset: 'Please run following command to reset your password',
|
||||
withGitHub: 'Continue with GitHub',
|
||||
withGoogle: 'Continue with Google',
|
||||
rightTitle: 'Unlock the full potential of LLM',
|
||||
rightDesc: 'Effortlessly build visually captivating, operable, and improvable AI applications.',
|
||||
tos: 'Terms of Service',
|
||||
pp: 'Privacy Policy',
|
||||
tosDesc: 'By signing up, you agree to our',
|
||||
donthave: 'Don\'t have?',
|
||||
invalidInvitationCode: 'Invalid invitation code',
|
||||
accountAlreadyInited: 'Account already inited',
|
||||
error: {
|
||||
emailEmpty: 'Email address is required',
|
||||
emailInValid: 'Please enter a valid email address',
|
||||
nameEmpty: 'Name is required',
|
||||
passwordEmpty: 'Password is required',
|
||||
passwordInvalid: 'Password must contain letters and numbers, and the length must be greater than 8',
|
||||
},
|
||||
license: {
|
||||
tip: 'Before starting Dify Community Edition, read the GitHub',
|
||||
link: 'Open-source License',
|
||||
},
|
||||
join: 'Join',
|
||||
joinTipStart: 'Invite you join',
|
||||
joinTipEnd: 'team on Dify',
|
||||
invalid: 'The link has expired',
|
||||
explore: 'Explore Dify',
|
||||
activatedTipStart: 'You have joined the',
|
||||
activatedTipEnd: 'team',
|
||||
activated: 'Sign In Now',
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
||||
@@ -1,41 +1,57 @@
|
||||
const translation = {
|
||||
"pageTitle": "嗨,近来可好 👋",
|
||||
"welcome": "欢迎来到 Dify, 登录以继续",
|
||||
"email": "邮箱",
|
||||
"password": "密码",
|
||||
"name": "用户名",
|
||||
"forget": "忘记密码?",
|
||||
"signBtn": "登录",
|
||||
"installBtn": "设置",
|
||||
"setAdminAccount": "设置管理员账户",
|
||||
"setAdminAccountDesc": "管理员拥有的最大权限,可用于创建应用和管理 LLM 供应商等。",
|
||||
"createAndSignIn": "创建账户",
|
||||
"oneMoreStep": "还差一步",
|
||||
"createSample": "基于这些信息,我们将为您创建一个示例应用",
|
||||
"invitationCode": "邀请码",
|
||||
"interfaceLanguage": "界面语言",
|
||||
"timezone": "时区",
|
||||
"go": "跳转至 Dify",
|
||||
"sendUsMail": "发封邮件介绍你自己,我们会尽快处理。",
|
||||
"acceptPP": "我已阅读并接受隐私政策",
|
||||
"reset": "请运行以下命令重置密码",
|
||||
"withGitHub": "使用 GitHub 登录",
|
||||
"withGoogle": "使用 Google 登录",
|
||||
"rightTitle": "释放大型语言模型的全部潜能",
|
||||
"rightDesc": "简单构建可视化、可运营、可改进的 AI 应用",
|
||||
"tos": "使用协议",
|
||||
"pp": "隐私政策",
|
||||
"tosDesc": "使用即代表你并同意我们的",
|
||||
"donthave": "还没有邀请码?",
|
||||
"invalidInvitationCode": "无效的邀请码",
|
||||
"accountAlreadyInited": "账户已经初始化",
|
||||
"error": {
|
||||
"emailEmpty": "邮箱不能为空",
|
||||
"emailInValid": "请输入有效的邮箱地址",
|
||||
"nameEmpty": "用户名不能为空",
|
||||
"passwordEmpty": "密码不能为空",
|
||||
"passwordInvalid": "密码必须包含字母和数字,且长度不小于8位",
|
||||
}
|
||||
pageTitle: '嗨,近来可好 👋',
|
||||
welcome: '欢迎来到 Dify, 登录以继续',
|
||||
email: '邮箱',
|
||||
emailPlaceholder: '输入邮箱地址',
|
||||
password: '密码',
|
||||
passwordPlaceholder: '输入密码',
|
||||
name: '用户名',
|
||||
namePlaceholder: '输入用户名',
|
||||
forget: '忘记密码?',
|
||||
signBtn: '登录',
|
||||
installBtn: '设置',
|
||||
setAdminAccount: '设置管理员账户',
|
||||
setAdminAccountDesc: '管理员拥有的最大权限,可用于创建应用和管理 LLM 供应商等。',
|
||||
createAndSignIn: '创建账户',
|
||||
oneMoreStep: '还差一步',
|
||||
createSample: '基于这些信息,我们将为您创建一个示例应用',
|
||||
invitationCode: '邀请码',
|
||||
invitationCodePlaceholder: '输入邀请码',
|
||||
interfaceLanguage: '界面语言',
|
||||
timezone: '时区',
|
||||
go: '跳转至 Dify',
|
||||
sendUsMail: '发封邮件介绍你自己,我们会尽快处理。',
|
||||
acceptPP: '我已阅读并接受隐私政策',
|
||||
reset: '请运行以下命令重置密码',
|
||||
withGitHub: '使用 GitHub 登录',
|
||||
withGoogle: '使用 Google 登录',
|
||||
rightTitle: '释放大型语言模型的全部潜能',
|
||||
rightDesc: '简单构建可视化、可运营、可改进的 AI 应用',
|
||||
tos: '使用协议',
|
||||
pp: '隐私政策',
|
||||
tosDesc: '使用即代表你并同意我们的',
|
||||
donthave: '还没有邀请码?',
|
||||
invalidInvitationCode: '无效的邀请码',
|
||||
accountAlreadyInited: '账户已经初始化',
|
||||
error: {
|
||||
emailEmpty: '邮箱不能为空',
|
||||
emailInValid: '请输入有效的邮箱地址',
|
||||
nameEmpty: '用户名不能为空',
|
||||
passwordEmpty: '密码不能为空',
|
||||
passwordInvalid: '密码必须包含字母和数字,且长度不小于8位',
|
||||
},
|
||||
license: {
|
||||
tip: '启动 Dify 社区版之前, 请阅读 GitHub 上的',
|
||||
link: '开源协议',
|
||||
},
|
||||
join: '加入',
|
||||
joinTipStart: '邀请你加入',
|
||||
joinTipEnd: '团队',
|
||||
invalid: '链接已失效',
|
||||
explore: '探索 Dify',
|
||||
activatedTipStart: '您已加入',
|
||||
activatedTipEnd: '团队',
|
||||
activated: '现在登录',
|
||||
}
|
||||
|
||||
export default translation
|
||||
|
||||