mirror of
https://gitee.com/dify_ai/dify.git
synced 2025-12-06 19:42:42 +08:00
Compare commits
27 Commits
feat/apply
...
feat/claud
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c3ff02d58 | ||
|
|
5f2fab31be | ||
|
|
66c6679637 | ||
|
|
bc5eb3fbd2 | ||
|
|
f9e913694b | ||
|
|
d1b82bb8e3 | ||
|
|
dee004bffd | ||
|
|
e8c14bb732 | ||
|
|
bf45f08e78 | ||
|
|
2c77a74c40 | ||
|
|
440cf63317 | ||
|
|
50b11e925b | ||
|
|
7cc81b4269 | ||
|
|
93b0813b73 | ||
|
|
649b44aefa | ||
|
|
1e95d74ae2 | ||
|
|
700d5f2673 | ||
|
|
3b8234e486 | ||
|
|
0feb0bf7c0 | ||
|
|
c5d148bf94 | ||
|
|
e5e86fc033 | ||
|
|
cc52cdc2a9 | ||
|
|
42a417167f | ||
|
|
4b0d9272ef | ||
|
|
48a303b8e9 | ||
|
|
8e15ba6cd6 | ||
|
|
7898937eae |
20
README.md
20
README.md
@@ -19,15 +19,23 @@ Visual data analysis, log review, and annotation for applications
|
||||
## Highlighted Features
|
||||
**1. LLMs support:** Choose capabilities based on different models when building your Dify AI apps. Dify is compatible with Langchain, meaning it will support various LLMs. Currently supported:
|
||||
|
||||
>* OpenAI: GPT-4, GPT-3.5-turbo, GPT-3.5-turbo-16k, text-davinci-003
|
||||
>* Azure OpenAI Service
|
||||
>* Anthropic: Claude2, Claude-instant
|
||||
>* Hugging Face Hub (coming soon)
|
||||
- [x] **OpenAI**: GPT4, GPT3.5-turbo, GPT3.5-turbo-16k, text-davinci-003
|
||||
- [x] **Azure OpenAI Service**
|
||||
- [x] **Anthropic**: Claude2, Claude-instant
|
||||
- [x] **Replicate**
|
||||
- [x] **Hugging Face Hub**
|
||||
- [x] **MiniMax**
|
||||
- [x] **Spark**
|
||||
- [x] **Wenxin**
|
||||
- [x] **Tongyi**
|
||||
- [x] **ChatGLM**
|
||||
|
||||
We provide the following free resources for registered Dify cloud users (sign up at [dify.ai](https://dify.ai)):
|
||||
* 1000 free Claude model queries to build Claude-powered apps
|
||||
* 200 free OpenAI queries to build OpenAI-based apps
|
||||
|
||||
* 3 million Xunfei Spark Tokens are provided for creating AI applications based on Spark.
|
||||
* 1 million MiniMax Tokens are provided for creating AI applications based on the MiniMax.
|
||||
|
||||
**2. Visual orchestration:** Build an AI app in minutes by writing and debugging prompts visually.
|
||||
|
||||
**3. Text embedding:** Fully automated text preprocessing embeds your data as context without complex concepts. Supports PDF, TXT, and syncing data from Notion, webpages, APIs.
|
||||
@@ -55,7 +63,7 @@ Visit [Dify.ai](https://dify.ai)
|
||||
|
||||
Before installing Dify, make sure your machine meets the following minimum system requirements:
|
||||
|
||||
- CPU >= 1 Core
|
||||
- CPU >= 2 Core
|
||||
- RAM >= 4GB
|
||||
|
||||
### Quick Start
|
||||
|
||||
18
README_CN.md
18
README_CN.md
@@ -22,14 +22,22 @@
|
||||
## 核心能力
|
||||
1. **模型支持:** 你可以在 Dify 上选择基于不同模型的能力来开发你的 AI 应用。Dify 兼容 Langchain,这意味着我们将逐步支持多种 LLMs ,目前支持的模型供应商:
|
||||
|
||||
> * **OpenAI**:GPT4、GPT3.5-turbo、GPT3.5-turbo-16k、text-davinci-003
|
||||
> * **Azure OpenAI Service**
|
||||
> * **Anthropic**:Claude2、Claude-instant
|
||||
> * **Hugging Face Hub**(即将推出)
|
||||
- [x] **OpenAI**:GPT4、GPT3.5-turbo、GPT3.5-turbo-16k、text-davinci-003
|
||||
- [x] **Azure OpenAI Service**
|
||||
- [x] **Anthropic**:Claude2、Claude-instant
|
||||
- [x] **Replicate**
|
||||
- [x] **Hugging Face Hub**
|
||||
- [x] **MiniMax**
|
||||
- [x] **讯飞星火大模型**
|
||||
- [x] **文心一言**
|
||||
- [x] **通义千问**
|
||||
- [x] **ChatGLM**
|
||||
|
||||
我们为所有注册云端版的用户免费提供以下资源(登录 [dify.ai](https://cloud.dify.ai) 即可使用):
|
||||
* 1000 次 Claude 模型的消息调用额度,用于创建基于 Claude 模型的 AI 应用
|
||||
* 200 次 OpenAI 模型的消息调用额度,用于创建基于 OpenAI 模型的 AI 应用
|
||||
* 300 万 讯飞星火大模型 Token 的调用额度,用于创建基于讯飞星火大模型的 AI 应用
|
||||
* 100 万 MiniMax Token 的调用额度,用于创建基于 MiniMax 模型的 AI 应用
|
||||
2. **可视化编排 Prompt:** 通过界面化编写 prompt 并调试,只需几分钟即可发布一个 AI 应用。
|
||||
3. **文本 Embedding 处理(数据集)**:全自动完成文本预处理,使用你的数据作为上下文,无需理解晦涩的概念和技术处理。支持 PDF、txt 等文件格式,支持从 Notion、网页、API 同步数据。
|
||||
4. **基于 API 开发:** 后端即服务。您可以直接访问网页应用,也可以接入 API 集成到您的应用中,无需关注复杂的后端架构和部署过程。
|
||||
@@ -53,7 +61,7 @@
|
||||
|
||||
在安装 Dify 之前,请确保您的机器满足以下最低系统要求:
|
||||
|
||||
- CPU >= 1 Core
|
||||
- CPU >= 2 Core
|
||||
- RAM >= 4GB
|
||||
|
||||
### 快速启动
|
||||
|
||||
@@ -32,7 +32,7 @@ Visita [Dify.ai](https://dify.ai)
|
||||
|
||||
Antes de instalar Dify, asegúrate de que tu máquina cumple con los siguientes requisitos mínimos del sistema:
|
||||
|
||||
- CPU >= 1 Core
|
||||
- CPU >= 2 Core
|
||||
- RAM >= 4GB
|
||||
|
||||
### Inicio rápido
|
||||
|
||||
@@ -9,17 +9,13 @@ SECRET_KEY=
|
||||
|
||||
# Console API base URL
|
||||
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
|
||||
SERVICE_API_URL=http://127.0.0.1:5001
|
||||
|
||||
# Web APP API base URL
|
||||
# Web APP 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
|
||||
@@ -121,10 +117,12 @@ HOSTED_AZURE_OPENAI_QUOTA_LIMIT=200
|
||||
HOSTED_ANTHROPIC_ENABLED=false
|
||||
HOSTED_ANTHROPIC_API_BASE=
|
||||
HOSTED_ANTHROPIC_API_KEY=
|
||||
HOSTED_ANTHROPIC_QUOTA_LIMIT=1000000
|
||||
HOSTED_ANTHROPIC_QUOTA_LIMIT=600000
|
||||
HOSTED_ANTHROPIC_PAID_ENABLED=false
|
||||
HOSTED_ANTHROPIC_PAID_STRIPE_PRICE_ID=
|
||||
HOSTED_ANTHROPIC_PAID_INCREASE_QUOTA=1
|
||||
HOSTED_ANTHROPIC_PAID_INCREASE_QUOTA=1000000
|
||||
HOSTED_ANTHROPIC_PAID_MIN_QUANTITY=20
|
||||
HOSTED_ANTHROPIC_PAID_MAX_QUANTITY=100
|
||||
|
||||
STRIPE_API_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
@@ -33,9 +33,30 @@
|
||||
```bash
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
⚠️ If you encounter problems with jieba, for example
|
||||
|
||||
```
|
||||
> flask db upgrade
|
||||
Error: While importing 'app', an ImportError was raised:
|
||||
```
|
||||
|
||||
Please run the following command instead.
|
||||
|
||||
```
|
||||
pip install -r requirements.txt --upgrade --force-reinstall
|
||||
```
|
||||
|
||||
6. Start backend:
|
||||
```bash
|
||||
flask run --host 0.0.0.0 --port=5001 --debug
|
||||
```
|
||||
7. Setup your application by visiting http://localhost:5001/console/api/setup or other apis...
|
||||
8. If you need to debug local async processing, you can run `celery -A app.celery worker -Q dataset,generation,mail`, celery can do dataset importing and other async tasks.
|
||||
8. If you need to debug local async processing, you can run `celery -A app.celery worker -Q dataset,generation,mail`, celery can do dataset importing and other async tasks.
|
||||
|
||||
8. Start frontend:
|
||||
|
||||
```
|
||||
docker run -it -d --platform linux/amd64 -p 3000:3000 -e EDITION=SELF_HOSTED -e CONSOLE_URL=http://127.0.0.1:5000 --name web-self-hosted langgenius/dify-web:latest
|
||||
```
|
||||
This will start a dify frontend, now you are all set, happy coding!
|
||||
@@ -258,6 +258,8 @@ def sync_anthropic_hosted_providers():
|
||||
click.echo(click.style('Start sync anthropic hosted providers.', fg='green'))
|
||||
count = 0
|
||||
|
||||
new_quota_limit = hosted_model_providers.anthropic.quota_limit
|
||||
|
||||
page = 1
|
||||
while True:
|
||||
try:
|
||||
@@ -265,6 +267,7 @@ def sync_anthropic_hosted_providers():
|
||||
Provider.provider_name == 'anthropic',
|
||||
Provider.provider_type == ProviderType.SYSTEM.value,
|
||||
Provider.quota_type == ProviderQuotaType.TRIAL.value,
|
||||
Provider.quota_limit != new_quota_limit
|
||||
).order_by(Provider.created_at.desc()).paginate(page=page, per_page=100)
|
||||
except NotFound:
|
||||
break
|
||||
@@ -272,9 +275,9 @@ def sync_anthropic_hosted_providers():
|
||||
page += 1
|
||||
for provider in providers:
|
||||
try:
|
||||
click.echo('Syncing tenant anthropic hosted provider: {}'.format(provider.tenant_id))
|
||||
click.echo('Syncing tenant anthropic hosted provider: {}, origin: limit {}, used {}'
|
||||
.format(provider.tenant_id, provider.quota_limit, provider.quota_used))
|
||||
original_quota_limit = provider.quota_limit
|
||||
new_quota_limit = hosted_model_providers.anthropic.quota_limit
|
||||
division = math.ceil(new_quota_limit / 1000)
|
||||
|
||||
provider.quota_limit = new_quota_limit if original_quota_limit == 1000 \
|
||||
|
||||
@@ -57,10 +57,12 @@ DEFAULTS = {
|
||||
'HOSTED_OPENAI_PAID_INCREASE_QUOTA': 1,
|
||||
'HOSTED_AZURE_OPENAI_ENABLED': 'False',
|
||||
'HOSTED_AZURE_OPENAI_QUOTA_LIMIT': 200,
|
||||
'HOSTED_ANTHROPIC_QUOTA_LIMIT': 1000000,
|
||||
'HOSTED_ANTHROPIC_QUOTA_LIMIT': 600000,
|
||||
'HOSTED_ANTHROPIC_ENABLED': 'False',
|
||||
'HOSTED_ANTHROPIC_PAID_ENABLED': 'False',
|
||||
'HOSTED_ANTHROPIC_PAID_INCREASE_QUOTA': 1,
|
||||
'HOSTED_ANTHROPIC_PAID_INCREASE_QUOTA': 1000000,
|
||||
'HOSTED_ANTHROPIC_PAID_MIN_QUANTITY': 20,
|
||||
'HOSTED_ANTHROPIC_PAID_MAX_QUANTITY': 100,
|
||||
'TENANT_DOCUMENT_COUNT': 100,
|
||||
'CLEAN_DAY_SETTING': 30
|
||||
}
|
||||
@@ -98,7 +100,7 @@ class Config:
|
||||
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.12"
|
||||
self.CURRENT_VERSION = "0.3.13"
|
||||
self.COMMIT_SHA = get_env('COMMIT_SHA')
|
||||
self.EDITION = "SELF_HOSTED"
|
||||
self.DEPLOY_ENV = get_env('DEPLOY_ENV')
|
||||
@@ -209,7 +211,7 @@ class Config:
|
||||
self.HOSTED_OPENAI_API_KEY = get_env('HOSTED_OPENAI_API_KEY')
|
||||
self.HOSTED_OPENAI_API_BASE = get_env('HOSTED_OPENAI_API_BASE')
|
||||
self.HOSTED_OPENAI_API_ORGANIZATION = get_env('HOSTED_OPENAI_API_ORGANIZATION')
|
||||
self.HOSTED_OPENAI_QUOTA_LIMIT = get_env('HOSTED_OPENAI_QUOTA_LIMIT')
|
||||
self.HOSTED_OPENAI_QUOTA_LIMIT = int(get_env('HOSTED_OPENAI_QUOTA_LIMIT'))
|
||||
self.HOSTED_OPENAI_PAID_ENABLED = get_bool_env('HOSTED_OPENAI_PAID_ENABLED')
|
||||
self.HOSTED_OPENAI_PAID_STRIPE_PRICE_ID = get_env('HOSTED_OPENAI_PAID_STRIPE_PRICE_ID')
|
||||
self.HOSTED_OPENAI_PAID_INCREASE_QUOTA = int(get_env('HOSTED_OPENAI_PAID_INCREASE_QUOTA'))
|
||||
@@ -217,15 +219,17 @@ class Config:
|
||||
self.HOSTED_AZURE_OPENAI_ENABLED = get_bool_env('HOSTED_AZURE_OPENAI_ENABLED')
|
||||
self.HOSTED_AZURE_OPENAI_API_KEY = get_env('HOSTED_AZURE_OPENAI_API_KEY')
|
||||
self.HOSTED_AZURE_OPENAI_API_BASE = get_env('HOSTED_AZURE_OPENAI_API_BASE')
|
||||
self.HOSTED_AZURE_OPENAI_QUOTA_LIMIT = get_env('HOSTED_AZURE_OPENAI_QUOTA_LIMIT')
|
||||
self.HOSTED_AZURE_OPENAI_QUOTA_LIMIT = int(get_env('HOSTED_AZURE_OPENAI_QUOTA_LIMIT'))
|
||||
|
||||
self.HOSTED_ANTHROPIC_ENABLED = get_bool_env('HOSTED_ANTHROPIC_ENABLED')
|
||||
self.HOSTED_ANTHROPIC_API_BASE = get_env('HOSTED_ANTHROPIC_API_BASE')
|
||||
self.HOSTED_ANTHROPIC_API_KEY = get_env('HOSTED_ANTHROPIC_API_KEY')
|
||||
self.HOSTED_ANTHROPIC_QUOTA_LIMIT = get_env('HOSTED_ANTHROPIC_QUOTA_LIMIT')
|
||||
self.HOSTED_ANTHROPIC_QUOTA_LIMIT = int(get_env('HOSTED_ANTHROPIC_QUOTA_LIMIT'))
|
||||
self.HOSTED_ANTHROPIC_PAID_ENABLED = get_bool_env('HOSTED_ANTHROPIC_PAID_ENABLED')
|
||||
self.HOSTED_ANTHROPIC_PAID_STRIPE_PRICE_ID = get_env('HOSTED_ANTHROPIC_PAID_STRIPE_PRICE_ID')
|
||||
self.HOSTED_ANTHROPIC_PAID_INCREASE_QUOTA = get_env('HOSTED_ANTHROPIC_PAID_INCREASE_QUOTA')
|
||||
self.HOSTED_ANTHROPIC_PAID_INCREASE_QUOTA = int(get_env('HOSTED_ANTHROPIC_PAID_INCREASE_QUOTA'))
|
||||
self.HOSTED_ANTHROPIC_PAID_MIN_QUANTITY = int(get_env('HOSTED_ANTHROPIC_PAID_MIN_QUANTITY'))
|
||||
self.HOSTED_ANTHROPIC_PAID_MAX_QUANTITY = int(get_env('HOSTED_ANTHROPIC_PAID_MAX_QUANTITY'))
|
||||
|
||||
self.STRIPE_API_KEY = get_env('STRIPE_API_KEY')
|
||||
self.STRIPE_WEBHOOK_SECRET = get_env('STRIPE_WEBHOOK_SECRET')
|
||||
|
||||
@@ -397,29 +397,6 @@ class AppApiStatus(Resource):
|
||||
return app
|
||||
|
||||
|
||||
class AppRateLimit(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@marshal_with(app_detail_fields)
|
||||
def post(self, app_id):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument('api_rpm', type=inputs.natural, required=False, location='json')
|
||||
parser.add_argument('api_rph', type=inputs.natural, required=False, location='json')
|
||||
args = parser.parse_args()
|
||||
|
||||
app_id = str(app_id)
|
||||
app = _get_app(app_id, current_user.current_tenant_id)
|
||||
|
||||
if args.get('api_rpm'):
|
||||
app.api_rpm = args.get('api_rpm')
|
||||
if args.get('api_rph'):
|
||||
app.api_rph = args.get('api_rph')
|
||||
app.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
return app
|
||||
|
||||
|
||||
class AppCopy(Resource):
|
||||
@staticmethod
|
||||
def create_app_copy(app):
|
||||
@@ -482,16 +459,6 @@ class AppCopy(Resource):
|
||||
return copy_app, 201
|
||||
|
||||
|
||||
class AppExport(Resource):
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self, app_id):
|
||||
# todo
|
||||
pass
|
||||
|
||||
|
||||
api.add_resource(AppListApi, '/apps')
|
||||
api.add_resource(AppTemplateApi, '/app-templates')
|
||||
api.add_resource(AppApi, '/apps/<uuid:app_id>')
|
||||
@@ -500,4 +467,3 @@ api.add_resource(AppNameApi, '/apps/<uuid:app_id>/name')
|
||||
api.add_resource(AppIconApi, '/apps/<uuid:app_id>/icon')
|
||||
api.add_resource(AppSiteStatus, '/apps/<uuid:app_id>/site-enable')
|
||||
api.add_resource(AppApiStatus, '/apps/<uuid:app_id>/api-enable')
|
||||
api.add_resource(AppRateLimit, '/apps/<uuid:app_id>/rate-limit')
|
||||
|
||||
@@ -80,6 +80,13 @@ class AppSite(Resource):
|
||||
if value is not None:
|
||||
setattr(site, attr_name, value)
|
||||
|
||||
if attr_name == 'title':
|
||||
app_model.name = value
|
||||
elif attr_name == 'icon':
|
||||
app_model.icon = value
|
||||
elif attr_name == 'icon_background':
|
||||
app_model.icon_background = value
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return site
|
||||
|
||||
@@ -38,12 +38,20 @@ class StripeWebhookApi(Resource):
|
||||
logging.debug(event['data']['object']['payment_status'])
|
||||
logging.debug(event['data']['object']['metadata'])
|
||||
|
||||
session = stripe.checkout.Session.retrieve(
|
||||
event['data']['object']['id'],
|
||||
expand=['line_items'],
|
||||
)
|
||||
|
||||
logging.debug(session.line_items['data'][0]['quantity'])
|
||||
|
||||
# Fulfill the purchase...
|
||||
provider_checkout_service = ProviderCheckoutService()
|
||||
|
||||
try:
|
||||
provider_checkout_service.fulfill_provider_order(event)
|
||||
provider_checkout_service.fulfill_provider_order(event, session.line_items)
|
||||
except Exception as e:
|
||||
|
||||
logging.debug(str(e))
|
||||
return 'success', 200
|
||||
|
||||
|
||||
@@ -270,6 +270,20 @@ class ModelProviderPaymentCheckoutUrlApi(Resource):
|
||||
}
|
||||
|
||||
|
||||
class ModelProviderFreeQuotaSubmitApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self, provider_name: str):
|
||||
provider_service = ProviderService()
|
||||
result = provider_service.free_quota_submit(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
provider_name=provider_name
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
api.add_resource(ModelProviderListApi, '/workspaces/current/model-providers')
|
||||
api.add_resource(ModelProviderValidateApi, '/workspaces/current/model-providers/<string:provider_name>/validate')
|
||||
api.add_resource(ModelProviderUpdateApi, '/workspaces/current/model-providers/<string:provider_name>')
|
||||
@@ -283,3 +297,5 @@ api.add_resource(ModelProviderModelParameterRuleApi,
|
||||
'/workspaces/current/model-providers/<string:provider_name>/models/parameter-rules')
|
||||
api.add_resource(ModelProviderPaymentCheckoutUrlApi,
|
||||
'/workspaces/current/model-providers/<string:provider_name>/checkout-url')
|
||||
api.add_resource(ModelProviderFreeQuotaSubmitApi,
|
||||
'/workspaces/current/model-providers/<string:provider_name>/free-quota-submit')
|
||||
|
||||
@@ -11,13 +11,13 @@ 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:
|
||||
app_code = request.headers.get('X-App-Code')
|
||||
if app_code 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.code == app_code,
|
||||
Site.status == 'normal'
|
||||
).first()
|
||||
if not site:
|
||||
@@ -41,6 +41,7 @@ class PassportResource(Resource):
|
||||
"iss": site.app_id,
|
||||
'sub': 'Web API Passport',
|
||||
'app_id': site.app_id,
|
||||
'app_code': app_code,
|
||||
'end_user_id': end_user.id,
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from flask_restful import Resource
|
||||
from werkzeug.exceptions import NotFound, Unauthorized
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.model import App, EndUser
|
||||
from models.model import App, EndUser, Site
|
||||
from libs.passport import PassportService
|
||||
|
||||
def validate_jwt_token(view=None):
|
||||
@@ -35,9 +35,13 @@ def decode_jwt_token():
|
||||
if auth_scheme != 'bearer':
|
||||
raise Unauthorized('Invalid Authorization header format. Expected \'Bearer <api-key>\' format.')
|
||||
decoded = PassportService().verify(tk)
|
||||
app_code = decoded.get('app_code')
|
||||
app_model = db.session.query(App).filter(App.id == decoded['app_id']).first()
|
||||
site = db.session.query(Site).filter(Site.code == app_code).first()
|
||||
if not app_model:
|
||||
raise NotFound()
|
||||
if not app_code and not site:
|
||||
raise Unauthorized('Site URL is no longer valid.')
|
||||
if app_model.enable_site is False:
|
||||
raise Unauthorized('Site is disabled.')
|
||||
end_user = db.session.query(EndUser).filter(EndUser.id == decoded['end_user_id']).first()
|
||||
|
||||
@@ -10,7 +10,7 @@ from langchain.schema import AgentAction, AgentFinish, OutputParserException
|
||||
class StructuredChatOutputParser(LCStructuredChatOutputParser):
|
||||
def parse(self, text: str) -> Union[AgentAction, AgentFinish]:
|
||||
try:
|
||||
action_match = re.search(r"```(.*?)\n(.*?)```?", text, re.DOTALL)
|
||||
action_match = re.search(r"```(.*?)\n?(.*?)```", text, re.DOTALL)
|
||||
if action_match is not None:
|
||||
response = json.loads(action_match.group(2).strip(), strict=False)
|
||||
if isinstance(response, list):
|
||||
|
||||
@@ -168,10 +168,34 @@ class ModelProviderFactory:
|
||||
model_provider_rules = ModelProviderFactory.get_provider_rule(model_provider_name)
|
||||
for quota_type_enum in ProviderQuotaType:
|
||||
quota_type = quota_type_enum.value
|
||||
if quota_type in model_provider_rules['system_config']['supported_quota_types'] \
|
||||
and quota_type in quota_type_to_provider_dict.keys():
|
||||
provider = quota_type_to_provider_dict[quota_type]
|
||||
if provider.is_valid and provider.quota_limit > provider.quota_used:
|
||||
if quota_type in model_provider_rules['system_config']['supported_quota_types']:
|
||||
if quota_type in quota_type_to_provider_dict.keys():
|
||||
provider = quota_type_to_provider_dict[quota_type]
|
||||
if provider.is_valid and provider.quota_limit > provider.quota_used:
|
||||
return provider
|
||||
elif quota_type == ProviderQuotaType.TRIAL.value:
|
||||
try:
|
||||
provider = Provider(
|
||||
tenant_id=tenant_id,
|
||||
provider_name=model_provider_name,
|
||||
provider_type=ProviderType.SYSTEM.value,
|
||||
is_valid=True,
|
||||
quota_type=ProviderQuotaType.TRIAL.value,
|
||||
quota_limit=model_provider_rules['system_config']['quota_limit'],
|
||||
quota_used=0
|
||||
)
|
||||
db.session.add(provider)
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
provider = db.session.query(Provider) \
|
||||
.filter(
|
||||
Provider.tenant_id == tenant_id,
|
||||
Provider.provider_name == model_provider_name,
|
||||
Provider.provider_type == ProviderType.SYSTEM.value,
|
||||
Provider.quota_type == ProviderQuotaType.TRIAL.value
|
||||
).first()
|
||||
|
||||
return provider
|
||||
|
||||
no_system_provider = True
|
||||
|
||||
@@ -125,6 +125,8 @@ class BaseLLM(BaseProviderModel):
|
||||
completion_tokens = self.get_num_tokens([PromptMessage(content=completion_content, type=MessageType.ASSISTANT)])
|
||||
total_tokens = prompt_tokens + completion_tokens
|
||||
|
||||
self.model_provider.update_last_used()
|
||||
|
||||
if self.deduct_quota:
|
||||
self.model_provider.deduct_quota(total_tokens)
|
||||
|
||||
@@ -218,15 +220,18 @@ class BaseLLM(BaseProviderModel):
|
||||
|
||||
def _get_prompt_from_messages(self, messages: List[PromptMessage],
|
||||
model_mode: Optional[ModelMode] = None) -> Union[str | List[BaseMessage]]:
|
||||
if len(messages) == 0:
|
||||
raise ValueError("prompt must not be empty.")
|
||||
|
||||
if not model_mode:
|
||||
model_mode = self.model_mode
|
||||
|
||||
if model_mode == ModelMode.COMPLETION:
|
||||
if len(messages) == 0:
|
||||
return ''
|
||||
|
||||
return messages[0].content
|
||||
else:
|
||||
if len(messages) == 0:
|
||||
return []
|
||||
|
||||
chat_messages = []
|
||||
for message in messages:
|
||||
if message.type == MessageType.HUMAN:
|
||||
|
||||
@@ -183,6 +183,8 @@ class AnthropicProvider(BaseModelProvider):
|
||||
return {
|
||||
'product_id': hosted_model_providers.anthropic.paid_stripe_price_id,
|
||||
'increase_quota': hosted_model_providers.anthropic.paid_increase_quota,
|
||||
'min_quantity': hosted_model_providers.anthropic.paid_min_quantity,
|
||||
'max_quantity': hosted_model_providers.anthropic.paid_max_quantity,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@@ -31,7 +31,9 @@ class HostedAnthropic(BaseModel):
|
||||
"""Quota limit for the anthropic hosted model. 0 means unlimited."""
|
||||
paid_enabled: bool = False
|
||||
paid_stripe_price_id: str = None
|
||||
paid_increase_quota: int = 1
|
||||
paid_increase_quota: int = 1000000
|
||||
paid_min_quantity: int = 20
|
||||
paid_max_quantity: int = 100
|
||||
|
||||
|
||||
class HostedModelProviders(BaseModel):
|
||||
@@ -73,4 +75,6 @@ def init_app(app: Flask):
|
||||
paid_enabled=app.config.get("HOSTED_ANTHROPIC_PAID_ENABLED"),
|
||||
paid_stripe_price_id=app.config.get("HOSTED_ANTHROPIC_PAID_STRIPE_PRICE_ID"),
|
||||
paid_increase_quota=app.config.get("HOSTED_ANTHROPIC_PAID_INCREASE_QUOTA"),
|
||||
paid_min_quantity=app.config.get("HOSTED_ANTHROPIC_PAID_MIN_QUANTITY"),
|
||||
paid_max_quantity=app.config.get("HOSTED_ANTHROPIC_PAID_MAX_QUANTITY"),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ import logging
|
||||
from json import JSONDecodeError
|
||||
from typing import Type
|
||||
|
||||
from flask import current_app
|
||||
from langchain.schema import HumanMessage
|
||||
|
||||
from core.helper import encrypter
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
],
|
||||
"system_config": {
|
||||
"supported_quota_types": [
|
||||
"paid",
|
||||
"trial"
|
||||
],
|
||||
"quota_unit": "times",
|
||||
"quota_limit": 1000
|
||||
"quota_unit": "tokens",
|
||||
"quota_limit": 600000
|
||||
},
|
||||
"model_flexibility": "fixed"
|
||||
}
|
||||
2
api/core/third_party/langchain/llms/spark.py
vendored
2
api/core/third_party/langchain/llms/spark.py
vendored
@@ -50,6 +50,7 @@ class ChatSpark(BaseChatModel):
|
||||
app_id: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
api_secret: Optional[str] = None
|
||||
api_domain: Optional[str] = None
|
||||
|
||||
@root_validator()
|
||||
def validate_environment(cls, values: Dict) -> Dict:
|
||||
@@ -68,6 +69,7 @@ class ChatSpark(BaseChatModel):
|
||||
app_id=values["app_id"],
|
||||
api_key=values["api_key"],
|
||||
api_secret=values["api_secret"],
|
||||
api_domain=values.get('api_domain')
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
4
api/core/third_party/spark/spark_llm.py
vendored
4
api/core/third_party/spark/spark_llm.py
vendored
@@ -16,9 +16,9 @@ import websocket
|
||||
|
||||
|
||||
class SparkLLMClient:
|
||||
def __init__(self, app_id: str, api_key: str, api_secret: str):
|
||||
def __init__(self, app_id: str, api_key: str, api_secret: str, api_domain: Optional[str] = None):
|
||||
|
||||
self.api_base = "ws://spark-api.xf-yun.com/v1.1/chat"
|
||||
self.api_base = "wss://spark-api.xf-yun.com/v1.1/chat" if not api_domain else ('wss://' + api_domain + '/v1.1/chat')
|
||||
self.app_id = app_id
|
||||
self.ws_url = self.create_url(
|
||||
urlparse(self.api_base).netloc,
|
||||
|
||||
@@ -39,6 +39,8 @@ class ProviderCheckoutService:
|
||||
raise ValueError(f'provider name {provider_name} not support payment')
|
||||
|
||||
payment_product_id = payment_info['product_id']
|
||||
payment_min_quantity = payment_info['min_quantity']
|
||||
payment_max_quantity = payment_info['max_quantity']
|
||||
|
||||
# create provider order
|
||||
provider_order = ProviderOrder(
|
||||
@@ -53,18 +55,29 @@ class ProviderCheckoutService:
|
||||
db.session.add(provider_order)
|
||||
db.session.flush()
|
||||
|
||||
line_item = {
|
||||
'price': f'{payment_product_id}',
|
||||
'quantity': payment_min_quantity
|
||||
}
|
||||
|
||||
if payment_min_quantity > 1 and payment_max_quantity != payment_min_quantity:
|
||||
line_item['adjustable_quantity'] = {
|
||||
'enabled': True,
|
||||
'minimum': payment_min_quantity,
|
||||
'maximum': payment_max_quantity
|
||||
}
|
||||
|
||||
try:
|
||||
# create stripe checkout session
|
||||
checkout_session = stripe.checkout.Session.create(
|
||||
line_items=[
|
||||
{
|
||||
'price': f'{payment_product_id}',
|
||||
'quantity': 1,
|
||||
},
|
||||
line_item
|
||||
],
|
||||
mode='payment',
|
||||
success_url=current_app.config.get("CONSOLE_WEB_URL") + '?provider_payment=succeeded',
|
||||
cancel_url=current_app.config.get("CONSOLE_WEB_URL") + '?provider_payment=cancelled',
|
||||
success_url=current_app.config.get("CONSOLE_WEB_URL")
|
||||
+ f'?provider_name={provider_name}&payment_result=succeeded',
|
||||
cancel_url=current_app.config.get("CONSOLE_WEB_URL")
|
||||
+ f'?provider_name={provider_name}&payment_result=cancelled',
|
||||
automatic_tax={'enabled': True},
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -76,7 +89,7 @@ class ProviderCheckoutService:
|
||||
|
||||
return ProviderCheckout(checkout_session)
|
||||
|
||||
def fulfill_provider_order(self, event):
|
||||
def fulfill_provider_order(self, event, line_items):
|
||||
provider_order = db.session.query(ProviderOrder) \
|
||||
.filter(ProviderOrder.payment_id == event['data']['object']['id']) \
|
||||
.first()
|
||||
@@ -85,7 +98,8 @@ class ProviderCheckoutService:
|
||||
raise ValueError(f'provider order not found, payment id: {event["data"]["object"]["id"]}')
|
||||
|
||||
if provider_order.payment_status != ProviderOrderPaymentStatus.WAIT_PAY.value:
|
||||
raise ValueError(f'provider order payment status is not wait pay, payment id: {event["data"]["object"]["id"]}')
|
||||
raise ValueError(
|
||||
f'provider order payment status is not wait pay, payment id: {event["data"]["object"]["id"]}')
|
||||
|
||||
provider_order.transaction_id = event['data']['object']['payment_intent']
|
||||
provider_order.currency = event['data']['object']['currency']
|
||||
@@ -110,10 +124,12 @@ class ProviderCheckoutService:
|
||||
model_provider = model_provider_class(provider=provider)
|
||||
payment_info = model_provider.get_payment_info()
|
||||
|
||||
quantity = line_items['data'][0]['quantity']
|
||||
|
||||
if not payment_info:
|
||||
increase_quota = 0
|
||||
else:
|
||||
increase_quota = int(payment_info['increase_quota'])
|
||||
increase_quota = int(payment_info['increase_quota']) * quantity
|
||||
|
||||
if increase_quota > 0:
|
||||
provider.quota_limit += increase_quota
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from core.model_providers.model_factory import ModelFactory
|
||||
from extensions.ext_database import db
|
||||
from core.model_providers.model_provider_factory import ModelProviderFactory
|
||||
@@ -23,6 +27,14 @@ class ProviderService:
|
||||
# get rules for all providers
|
||||
model_provider_rules = ModelProviderFactory.get_provider_rules()
|
||||
model_provider_names = [model_provider_name for model_provider_name, _ in model_provider_rules.items()]
|
||||
|
||||
for model_provider_name, model_provider_rule in model_provider_rules.items():
|
||||
if ProviderType.SYSTEM.value in model_provider_rule['support_provider_types'] \
|
||||
and 'system_config' in model_provider_rule and model_provider_rule['system_config'] \
|
||||
and 'supported_quota_types' in model_provider_rule['system_config'] \
|
||||
and 'trial' in model_provider_rule['system_config']['supported_quota_types']:
|
||||
ModelProviderFactory.get_preferred_model_provider(tenant_id, model_provider_name)
|
||||
|
||||
configurable_model_provider_names = [
|
||||
model_provider_name
|
||||
for model_provider_name, model_provider_rules in model_provider_rules.items()
|
||||
@@ -121,12 +133,14 @@ class ProviderService:
|
||||
provider_parameter_dict[key]['is_valid'] = provider.is_valid
|
||||
provider_parameter_dict[key]['quota_used'] = provider.quota_used
|
||||
provider_parameter_dict[key]['quota_limit'] = provider.quota_limit
|
||||
provider_parameter_dict[key]['last_used'] = provider.last_used
|
||||
provider_parameter_dict[key]['last_used'] = int(provider.last_used.timestamp()) \
|
||||
if provider.last_used else None
|
||||
elif provider.provider_type == ProviderType.CUSTOM.value \
|
||||
and ProviderType.CUSTOM.value in provider_parameter_dict:
|
||||
# if custom
|
||||
key = ProviderType.CUSTOM.value
|
||||
provider_parameter_dict[key]['last_used'] = provider.last_used
|
||||
provider_parameter_dict[key]['last_used'] = int(provider.last_used.timestamp()) \
|
||||
if provider.last_used else None
|
||||
provider_parameter_dict[key]['is_valid'] = provider.is_valid
|
||||
|
||||
if model_provider_rule['model_flexibility'] == 'fixed':
|
||||
@@ -501,3 +515,33 @@ class ProviderService:
|
||||
# get model parameter rules
|
||||
return model_provider.get_model_parameter_rules(model_name, ModelType.value_of(model_type))
|
||||
|
||||
def free_quota_submit(self, tenant_id: str, provider_name: str):
|
||||
api_key = os.environ.get("FREE_QUOTA_APPLY_API_KEY")
|
||||
api_url = os.environ.get("FREE_QUOTA_APPLY_URL")
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f"Bearer {api_key}"
|
||||
}
|
||||
response = requests.post(api_url, headers=headers, json={'workspace_id': tenant_id, 'provider_name': provider_name})
|
||||
if not response.ok:
|
||||
logging.error(f"Request FREE QUOTA APPLY SERVER Error: {response.status_code} ")
|
||||
raise ValueError(f"Error: {response.status_code} ")
|
||||
|
||||
if response.json()["code"] != 'success':
|
||||
raise ValueError(
|
||||
f"error: {response.json()['message']}"
|
||||
)
|
||||
|
||||
rst = response.json()
|
||||
|
||||
if rst['type'] == 'redirect':
|
||||
return {
|
||||
'type': rst['type'],
|
||||
'redirect_url': rst['redirect_url']
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'type': rst['type'],
|
||||
'result': 'success'
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from flask_login import current_user
|
||||
from extensions.ext_database import db
|
||||
from models.account import Tenant
|
||||
from models.account import Tenant, TenantAccountJoin
|
||||
from models.provider import Provider
|
||||
|
||||
|
||||
class WorkspaceService:
|
||||
@classmethod
|
||||
def get_tenant_info(cls, tenant: Tenant):
|
||||
if not tenant:
|
||||
return None
|
||||
tenant_info = {
|
||||
'id': tenant.id,
|
||||
'name': tenant.name,
|
||||
@@ -13,10 +16,18 @@ class WorkspaceService:
|
||||
'status': tenant.status,
|
||||
'created_at': tenant.created_at,
|
||||
'providers': [],
|
||||
'in_trial': True,
|
||||
'trial_end_reason': None
|
||||
'in_trail': True,
|
||||
'trial_end_reason': None,
|
||||
'role': 'normal',
|
||||
}
|
||||
|
||||
# Get role of user
|
||||
tenant_account_join = db.session.query(TenantAccountJoin).filter(
|
||||
TenantAccountJoin.tenant_id == tenant.id,
|
||||
TenantAccountJoin.account_id == current_user.id
|
||||
).first()
|
||||
tenant_info['role'] = tenant_account_join.role
|
||||
|
||||
# Get providers
|
||||
providers = db.session.query(Provider).filter(
|
||||
Provider.tenant_id == tenant.id
|
||||
|
||||
@@ -2,7 +2,7 @@ version: '3.1'
|
||||
services:
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:0.3.12
|
||||
image: langgenius/dify-api:0.3.13
|
||||
restart: always
|
||||
environment:
|
||||
# Startup mode, 'api' starts the API server.
|
||||
@@ -124,7 +124,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing the queue.
|
||||
worker:
|
||||
image: langgenius/dify-api:0.3.12
|
||||
image: langgenius/dify-api:0.3.13
|
||||
restart: always
|
||||
environment:
|
||||
# Startup mode, 'worker' starts the Celery worker for processing the queue.
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:0.3.12
|
||||
image: langgenius/dify-web:0.3.13
|
||||
restart: always
|
||||
environment:
|
||||
EDITION: SELF_HOSTED
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import cn from 'classnames'
|
||||
import useSWR from 'swr'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import s from './style.module.css'
|
||||
import AppSideBar from '@/app/components/app-sidebar'
|
||||
import { fetchAppDetail } from '@/service/apps'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
export type IAppDetailLayoutProps = {
|
||||
children: React.ReactNode
|
||||
@@ -31,15 +32,21 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
params: { appId }, // get appId in path
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const detailParams = { url: '/apps', id: appId }
|
||||
const { data: response } = useSWR(detailParams, fetchAppDetail)
|
||||
|
||||
const navigation = [
|
||||
{ name: t('common.appMenus.overview'), href: `/app/${appId}/overview`, icon: ChartBarSquareIcon, selectedIcon: ChartBarSquareSolidIcon },
|
||||
{ name: t('common.appMenus.promptEng'), href: `/app/${appId}/configuration`, icon: Cog8ToothIcon, selectedIcon: Cog8ToothSolidIcon },
|
||||
{ name: t('common.appMenus.apiAccess'), href: `/app/${appId}/develop`, icon: CommandLineIcon, selectedIcon: CommandLineSolidIcon },
|
||||
{ name: t('common.appMenus.logAndAnn'), href: `/app/${appId}/logs`, icon: DocumentTextIcon, selectedIcon: DocumentTextSolidIcon },
|
||||
]
|
||||
const navigation = useMemo(() => {
|
||||
const navs = [
|
||||
{ name: t('common.appMenus.overview'), href: `/app/${appId}/overview`, icon: ChartBarSquareIcon, selectedIcon: ChartBarSquareSolidIcon },
|
||||
{ name: t('common.appMenus.apiAccess'), href: `/app/${appId}/develop`, icon: CommandLineIcon, selectedIcon: CommandLineSolidIcon },
|
||||
{ name: t('common.appMenus.logAndAnn'), href: `/app/${appId}/logs`, icon: DocumentTextIcon, selectedIcon: DocumentTextSolidIcon },
|
||||
]
|
||||
if (isCurrentWorkspaceManager)
|
||||
navs.push({ name: t('common.appMenus.promptEng'), href: `/app/${appId}/configuration`, icon: Cog8ToothIcon, selectedIcon: Cog8ToothSolidIcon })
|
||||
return navs
|
||||
}, [appId, isCurrentWorkspaceManager, t])
|
||||
|
||||
const appModeName = response?.mode?.toUpperCase() === 'COMPLETION' ? t('common.appModes.completionApp') : t('common.appModes.chatApp')
|
||||
useEffect(() => {
|
||||
if (response?.name)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import { EditKeyPopover } from './welcome-banner'
|
||||
import ChartView from './chartView'
|
||||
import CardView from './cardView'
|
||||
import { getLocaleOnServer } from '@/i18n/server'
|
||||
@@ -21,7 +20,6 @@ const Overview = async ({
|
||||
<ApikeyInfoPanel />
|
||||
<div className='flex flex-row items-center justify-between mb-4 text-xl text-gray-900'>
|
||||
{t('overview.title')}
|
||||
<EditKeyPopover />
|
||||
</div>
|
||||
<CardView appId={appId} />
|
||||
<ChartView appId={appId} />
|
||||
|
||||
@@ -12,7 +12,7 @@ import Confirm from '@/app/components/base/confirm'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { deleteApp } from '@/service/apps'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import AppsContext from '@/context/app-context'
|
||||
import AppsContext, { useAppContext } from '@/context/app-context'
|
||||
|
||||
export type AppCardProps = {
|
||||
app: App
|
||||
@@ -25,6 +25,7 @@ const AppCard = ({
|
||||
}: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
|
||||
const mutateApps = useContextSelector(AppsContext, state => state.mutateApps)
|
||||
|
||||
@@ -55,7 +56,8 @@ const AppCard = ({
|
||||
<div className={style.listItemHeading}>
|
||||
<div className={style.listItemHeadingContent}>{app.name}</div>
|
||||
</div>
|
||||
<span className={style.deleteAppIcon} onClick={onDeleteClick} />
|
||||
{ isCurrentWorkspaceManager
|
||||
&& <span className={style.deleteAppIcon} onClick={onDeleteClick} />}
|
||||
</div>
|
||||
<div className={style.listItemDescription}>{app.model_config?.pre_prompt}</div>
|
||||
<div className={style.listItemFooter}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import AppCard from './AppCard'
|
||||
import NewAppCard from './NewAppCard'
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import { fetchAppList } from '@/service/apps'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
import { useAppContext, useSelector } from '@/context/app-context'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
|
||||
const getKey = (pageIndex: number, previousPageData: AppListResponse) => {
|
||||
@@ -19,6 +19,7 @@ const getKey = (pageIndex: number, previousPageData: AppListResponse) => {
|
||||
|
||||
const Apps = () => {
|
||||
const { t } = useTranslation()
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const { data, isLoading, setSize, mutate } = useSWRInfinite(getKey, fetchAppList, { revalidateFirstPage: false })
|
||||
const loadingStateRef = useRef(false)
|
||||
const pageContainerRef = useSelector(state => state.pageContainerRef)
|
||||
@@ -55,7 +56,8 @@ const Apps = () => {
|
||||
{data?.map(({ data: apps }) => apps.map(app => (
|
||||
<AppCard key={app.id} app={app} onDelete={mutate} />
|
||||
)))}
|
||||
<NewAppCard ref={anchorRef} onSuccess={mutate} />
|
||||
{ isCurrentWorkspaceManager
|
||||
&& <NewAppCard ref={anchorRef} onSuccess={mutate} />}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import NewDatasetCard from './NewDatasetCard'
|
||||
import DatasetCard from './DatasetCard'
|
||||
import type { DataSetListResponse } from '@/models/datasets'
|
||||
import { fetchDatasets } from '@/service/datasets'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
import { useAppContext, useSelector } from '@/context/app-context'
|
||||
|
||||
const getKey = (pageIndex: number, previousPageData: DataSetListResponse) => {
|
||||
if (!pageIndex || previousPageData.has_more)
|
||||
@@ -16,6 +16,7 @@ const getKey = (pageIndex: number, previousPageData: DataSetListResponse) => {
|
||||
}
|
||||
|
||||
const Datasets = () => {
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const { data, isLoading, setSize, mutate } = useSWRInfinite(getKey, fetchDatasets, { revalidateFirstPage: false })
|
||||
const loadingStateRef = useRef(false)
|
||||
const pageContainerRef = useSelector(state => state.pageContainerRef)
|
||||
@@ -44,7 +45,7 @@ const Datasets = () => {
|
||||
{data?.map(({ data: datasets }) => datasets.map(dataset => (
|
||||
<DatasetCard key={dataset.id} dataset={dataset} onDelete={mutate} />),
|
||||
))}
|
||||
<NewDatasetCard ref={anchorRef} />
|
||||
{ isCurrentWorkspaceManager && <NewDatasetCard ref={anchorRef} /> }
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
'use client'
|
||||
import React, { FC, useState, useEffect } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import ModalFoot from '../modal-foot'
|
||||
import ConfigSelect, { Options } from '../config-select'
|
||||
import type { Options } from '../config-select'
|
||||
import ConfigSelect from '../config-select'
|
||||
import ConfigString from '../config-string'
|
||||
import SelectTypeItem from '../select-type-item'
|
||||
import s from './style.module.css'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import SelectTypeItem from '../select-type-item'
|
||||
import { getNewVar } from '@/utils/var'
|
||||
|
||||
import s from './style.module.css'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
|
||||
export interface IConfigModalProps {
|
||||
export type IConfigModalProps = {
|
||||
payload: PromptVariable
|
||||
type?: string
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
onConfirm: (newValue: { type: string, value: any }) => void
|
||||
onConfirm: (newValue: { type: string; value: any }) => void
|
||||
}
|
||||
|
||||
const ConfigModal: FC<IConfigModalProps> = ({
|
||||
payload,
|
||||
isShow,
|
||||
onClose,
|
||||
onConfirm
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { type, name, key, options, max_length } = payload || getNewVar('')
|
||||
@@ -42,7 +44,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
const isStringInput = tempType === 'string'
|
||||
const title = isStringInput ? t('appDebug.variableConig.maxLength') : t('appDebug.variableConig.options')
|
||||
|
||||
// string type
|
||||
// string type
|
||||
const [tempMaxLength, setTempMaxValue] = useState(max_length)
|
||||
useEffect(() => {
|
||||
setTempMaxValue(max_length)
|
||||
@@ -57,14 +59,15 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
const handleConfirm = () => {
|
||||
if (isStringInput) {
|
||||
onConfirm({ type: tempType, value: tempMaxLength })
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
if (tempOptions.length === 0) {
|
||||
Toast.notify({ type: 'error', message: 'At least one option requied' })
|
||||
return
|
||||
}
|
||||
const obj: Record<string, boolean> = {}
|
||||
let hasRepeatedItem = false
|
||||
tempOptions.forEach(o => {
|
||||
tempOptions.forEach((o) => {
|
||||
if (obj[o]) {
|
||||
hasRepeatedItem = true
|
||||
return
|
||||
@@ -97,11 +100,13 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
|
||||
<div className='mt-6'>
|
||||
<div className={s.title}>{title}</div>
|
||||
{isStringInput ? (
|
||||
<ConfigString value={tempMaxLength} onChange={setTempMaxValue} />
|
||||
) : (
|
||||
<ConfigSelect options={tempOptions} onChange={setTempOptions} />
|
||||
)}
|
||||
{isStringInput
|
||||
? (
|
||||
<ConfigString value={tempMaxLength} onChange={setTempMaxValue} />
|
||||
)
|
||||
: (
|
||||
<ConfigSelect options={tempOptions} onChange={setTempOptions} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
'use client'
|
||||
import React, { FC, } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
export interface IConfigStringProps {
|
||||
export type IConfigStringProps = {
|
||||
value: number | undefined
|
||||
onChange: (value: number | undefined) => void
|
||||
}
|
||||
|
||||
const MAX_LENGTH = 64
|
||||
const MAX_LENGTH = 256
|
||||
|
||||
const ConfigString: FC<IConfigStringProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
@@ -20,13 +20,8 @@ const ConfigString: FC<IConfigStringProps> = ({
|
||||
max={MAX_LENGTH}
|
||||
min={1}
|
||||
value={value || ''}
|
||||
onChange={e => {
|
||||
let value = parseInt(e.target.value, 10)
|
||||
if (value > MAX_LENGTH) {
|
||||
value = MAX_LENGTH
|
||||
} else if (value < 1) {
|
||||
value = 1
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = Math.max(1, Math.min(MAX_LENGTH, parseInt(e.target.value))) || 1
|
||||
onChange(value)
|
||||
}}
|
||||
className="w-full px-3 text-sm leading-9 text-gray-900 border-0 rounded-lg grow h-9 bg-gray-50 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-gray-200"
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Cog8ToothIcon, TrashIcon } from '@heroicons/react/24/outline'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import type { Timeout } from 'ahooks/lib/useRequest/src/types'
|
||||
import Panel from '../base/feature-panel'
|
||||
import OperationBtn from '../base/operation-btn'
|
||||
import VarIcon from '../base/icons/var-icon'
|
||||
@@ -23,6 +24,8 @@ export type IConfigVarProps = {
|
||||
onPromptVariablesChange?: (promptVariables: PromptVariable[]) => void
|
||||
}
|
||||
|
||||
let conflictTimer: Timeout
|
||||
|
||||
const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVariablesChange }) => {
|
||||
const { t } = useTranslation()
|
||||
const hasVar = promptVariables.length > 0
|
||||
@@ -35,9 +38,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
})()
|
||||
|
||||
const updatePromptVariable = (key: string, updateKey: string, newValue: any) => {
|
||||
if (!(key in promptVariableObj))
|
||||
return
|
||||
const newPromptVariables = promptVariables.map((item) => {
|
||||
const newPromptVariables = promptVariables.map((item, i) => {
|
||||
if (item.key === key) {
|
||||
return {
|
||||
...item,
|
||||
@@ -47,13 +48,10 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
|
||||
return item
|
||||
})
|
||||
|
||||
onPromptVariablesChange?.(newPromptVariables)
|
||||
}
|
||||
|
||||
const batchUpdatePromptVariable = (key: string, updateKeys: string[], newValues: any[]) => {
|
||||
if (!(key in promptVariableObj))
|
||||
return
|
||||
const newPromptVariables = promptVariables.map((item) => {
|
||||
if (item.key === key) {
|
||||
const newItem: any = { ...item }
|
||||
@@ -68,8 +66,8 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
|
||||
onPromptVariablesChange?.(newPromptVariables)
|
||||
}
|
||||
|
||||
const updatePromptKey = (index: number, newKey: string) => {
|
||||
clearTimeout(conflictTimer)
|
||||
const { isValid, errorKey, errorMessageKey } = checkKeys([newKey], true)
|
||||
if (!isValid) {
|
||||
Toast.notify({
|
||||
@@ -78,6 +76,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const newPromptVariables = promptVariables.map((item, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
@@ -85,10 +84,19 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
key: newKey,
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
|
||||
conflictTimer = setTimeout(() => {
|
||||
const isKeyExists = promptVariables.some(item => item.key.trim() === newKey.trim())
|
||||
if (isKeyExists) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('appDebug.varKeyError.keyAlreadyExists', { key: newKey }),
|
||||
})
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
onPromptVariablesChange?.(newPromptVariables)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,22 @@ import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from 'classnames'
|
||||
import useSWR from 'swr'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Progress from './progress'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { LinkExternal02, XClose } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import AccountSetting from '@/app/components/header/account-setting'
|
||||
import { fetchTenantInfo } from '@/service/common'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import I18n from '@/context/i18n'
|
||||
import ProviderConfig from '@/app/components/header/account-setting/model-page/configs'
|
||||
|
||||
const APIKeyInfoPanel: FC = () => {
|
||||
const isCloud = !IS_CE_EDITION
|
||||
const { providers }: any = useProviderContext()
|
||||
const { locale } = useContext(I18n)
|
||||
|
||||
const { textGenerationModelList } = useProviderContext()
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -22,37 +26,43 @@ const APIKeyInfoPanel: FC = () => {
|
||||
|
||||
const [isShow, setIsShow] = useState(true)
|
||||
|
||||
const { data: userInfo } = useSWR({ url: '/info' }, fetchTenantInfo)
|
||||
if (!userInfo)
|
||||
return null
|
||||
const hasSetAPIKEY = !!textGenerationModelList?.find(({ model_provider: provider }) => {
|
||||
if (provider.provider_type === 'system' && provider.quota_type === 'paid')
|
||||
return true
|
||||
|
||||
const hasBindAPI = userInfo?.providers?.find(({ token_is_set }) => token_is_set)
|
||||
if (hasBindAPI)
|
||||
if (provider.provider_type === 'custom')
|
||||
return true
|
||||
|
||||
return false
|
||||
})
|
||||
if (hasSetAPIKEY)
|
||||
return null
|
||||
|
||||
// first show in trail and not used exhausted, else find the exhausted
|
||||
const [used, total, providerName] = (() => {
|
||||
if (!providers || !isCloud)
|
||||
return [0, 0, '']
|
||||
const [used, total, unit, providerName] = (() => {
|
||||
if (!textGenerationModelList || !isCloud)
|
||||
return [0, 0, '', '']
|
||||
|
||||
let used = 0
|
||||
let total = 0
|
||||
let unit = 'times'
|
||||
let trailProviderName = ''
|
||||
let hasFoundNotExhausted = false
|
||||
Object.keys(providers).forEach((providerName) => {
|
||||
textGenerationModelList?.filter(({ model_provider: provider }) => {
|
||||
return provider.quota_type === 'trial'
|
||||
}).forEach(({ model_provider: provider }) => {
|
||||
if (hasFoundNotExhausted)
|
||||
return
|
||||
providers[providerName].providers.forEach(({ quota_type, quota_limit, quota_used }: any) => {
|
||||
if (quota_type === 'trial') {
|
||||
if (quota_limit !== quota_used)
|
||||
hasFoundNotExhausted = true
|
||||
|
||||
used = quota_used
|
||||
total = quota_limit
|
||||
trailProviderName = providerName
|
||||
}
|
||||
})
|
||||
const { provider_name, quota_used, quota_limit, quota_unit } = provider
|
||||
if (quota_limit !== quota_used)
|
||||
hasFoundNotExhausted = true
|
||||
used = quota_used
|
||||
total = quota_limit
|
||||
unit = quota_unit
|
||||
trailProviderName = provider_name
|
||||
})
|
||||
return [used, total, trailProviderName]
|
||||
|
||||
return [used, total, unit, trailProviderName]
|
||||
})()
|
||||
const usedPercent = Math.round(used / total * 100)
|
||||
const exhausted = isCloud && usedPercent === 100
|
||||
@@ -65,7 +75,7 @@ const APIKeyInfoPanel: FC = () => {
|
||||
{isCloud && <em-emoji id={exhausted ? '🤔' : '😀'} />}
|
||||
{isCloud
|
||||
? (
|
||||
<div>{t(`appOverview.apiKeyInfo.cloud.${exhausted ? 'exhausted' : 'trial'}.title`, { providerName })}</div>
|
||||
<div>{t(`appOverview.apiKeyInfo.cloud.${exhausted ? 'exhausted' : 'trial'}.title`, { providerName: (ProviderConfig as any)[providerName as string]?.selector?.name[locale] || providerName })}</div>
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
@@ -81,9 +91,9 @@ const APIKeyInfoPanel: FC = () => {
|
||||
{isCloud && (
|
||||
<div className='my-5'>
|
||||
<div className='flex items-center h-5 space-x-2 text-sm text-gray-700 font-medium'>
|
||||
<div>{t('appOverview.apiKeyInfo.callTimes')}</div>
|
||||
<div>{t(`appOverview.apiKeyInfo.${unit === 'times' ? 'callTimes' : 'usedToken'}`)}</div>
|
||||
<div>·</div>
|
||||
<div className={cn('font-semibold', exhausted && 'text-[#D92D20]')}>{used}/{total}</div>
|
||||
<div className={cn('font-semibold', exhausted && 'text-[#D92D20]')}>{formatNumber(used)}/{formatNumber(total)}</div>
|
||||
</div>
|
||||
<Progress className='mt-2' value={usedPercent} />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import {
|
||||
Cog8ToothIcon,
|
||||
DocumentTextIcon,
|
||||
@@ -22,6 +22,7 @@ import Switch from '@/app/components/base/switch'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import './style.css'
|
||||
import { AppType } from '@/types/app'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
export type IAppCardProps = {
|
||||
className?: string
|
||||
@@ -48,22 +49,30 @@ function AppCard({
|
||||
}: IAppCardProps) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { currentWorkspace, isCurrentWorkspaceManager } = useAppContext()
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [showEmbedded, setShowEmbedded] = useState(false)
|
||||
const [showCustomizeModal, setShowCustomizeModal] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const OPERATIONS_MAP = {
|
||||
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: [],
|
||||
}
|
||||
const OPERATIONS_MAP = useMemo(() => {
|
||||
const operationsMap = {
|
||||
webapp: [
|
||||
{ opName: t('appOverview.overview.appInfo.preview'), opIcon: RocketLaunchIcon },
|
||||
{ opName: t('appOverview.overview.appInfo.share.entry'), opIcon: ShareIcon },
|
||||
] as { opName: string; opIcon: any }[],
|
||||
api: [{ opName: t('appOverview.overview.apiInfo.doc'), opIcon: DocumentTextIcon }],
|
||||
app: [],
|
||||
}
|
||||
if (appInfo.mode === AppType.chat)
|
||||
operationsMap.webapp.push({ opName: t('appOverview.overview.appInfo.embedded.entry'), opIcon: EmbedIcon })
|
||||
|
||||
if (isCurrentWorkspaceManager)
|
||||
operationsMap.webapp.push({ opName: t('appOverview.overview.appInfo.settings.entry'), opIcon: Cog8ToothIcon })
|
||||
|
||||
return operationsMap
|
||||
}, [isCurrentWorkspaceManager, appInfo, t])
|
||||
|
||||
const isApp = cardType === 'app' || cardType === 'webapp'
|
||||
const basicName = isApp ? appInfo?.site?.title : t('appOverview.overview.apiInfo.title')
|
||||
@@ -129,7 +138,7 @@ function AppCard({
|
||||
<Tag className="mr-2" color={runningStatus ? 'green' : 'yellow'}>
|
||||
{runningStatus ? t('appOverview.overview.status.running') : t('appOverview.overview.status.disable')}
|
||||
</Tag>
|
||||
<Switch defaultValue={runningStatus} onChange={onChangeStatus} />
|
||||
<Switch defaultValue={runningStatus} onChange={onChangeStatus} disabled={currentWorkspace?.role === 'normal'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center py-2">
|
||||
@@ -200,6 +209,7 @@ function AppCard({
|
||||
onClose={() => setShowShareModal(false)}
|
||||
linkUrl={appUrl}
|
||||
onGenerateCode={onGenerateCode}
|
||||
regeneratable={isCurrentWorkspaceManager}
|
||||
/>
|
||||
<SettingsModal
|
||||
appInfo={appInfo}
|
||||
|
||||
@@ -17,6 +17,7 @@ type IShareLinkProps = {
|
||||
onClose: () => void
|
||||
onGenerateCode: () => Promise<void>
|
||||
linkUrl: string
|
||||
regeneratable?: boolean
|
||||
}
|
||||
|
||||
const prefixShare = 'appOverview.overview.appInfo.share'
|
||||
@@ -26,6 +27,7 @@ const ShareLinkModal: FC<IShareLinkProps> = ({
|
||||
isShow,
|
||||
onClose,
|
||||
onGenerateCode,
|
||||
regeneratable,
|
||||
}) => {
|
||||
const [genLoading, setGenLoading] = useState(false)
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
@@ -51,7 +53,7 @@ const ShareLinkModal: FC<IShareLinkProps> = ({
|
||||
<LinkIcon className='w-4 h-4 mr-2' />
|
||||
{ t(`${prefixShare}.${isCopied ? 'linkCopied' : 'copyLink'}`) }
|
||||
</Button>
|
||||
<Button className='w-32 !px-0' onClick={async () => {
|
||||
{regeneratable && <Button className='w-32 !px-0' onClick={async () => {
|
||||
setGenLoading(true)
|
||||
await onGenerateCode()
|
||||
setGenLoading(false)
|
||||
@@ -59,7 +61,7 @@ const ShareLinkModal: FC<IShareLinkProps> = ({
|
||||
}}>
|
||||
<ArrowPathIcon className={`w-4 h-4 mr-2 ${genLoading ? 'generateLogo' : ''}`} />
|
||||
{t(`${prefixShare}.regenerate`)}
|
||||
</Button>
|
||||
</Button>}
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="link-external-01">
|
||||
<path id="Icon" d="M10.5 4.5L10.5 1.5M10.5 1.5H7.5M10.5 1.5L6.5 5.5M5 2.5H3.9C3.05992 2.5 2.63988 2.5 2.31901 2.66349C2.03677 2.8073 1.8073 3.03677 1.66349 3.31901C1.5 3.63988 1.5 4.05992 1.5 4.9V8.1C1.5 8.94008 1.5 9.36012 1.66349 9.68099C1.8073 9.96323 2.03677 10.1927 2.31901 10.3365C2.63988 10.5 3.05992 10.5 3.9 10.5H7.1C7.94008 10.5 8.36012 10.5 8.68099 10.3365C8.96323 10.1927 9.1927 9.96323 9.33651 9.68099C9.5 9.36012 9.5 8.94008 9.5 8.1V7" stroke="#155EEF" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 650 B |
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "12",
|
||||
"height": "12",
|
||||
"viewBox": "0 0 12 12",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"id": "link-external-01"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"id": "Icon",
|
||||
"d": "M10.5 4.5L10.5 1.5M10.5 1.5H7.5M10.5 1.5L6.5 5.5M5 2.5H3.9C3.05992 2.5 2.63988 2.5 2.31901 2.66349C2.03677 2.8073 1.8073 3.03677 1.66349 3.31901C1.5 3.63988 1.5 4.05992 1.5 4.9V8.1C1.5 8.94008 1.5 9.36012 1.66349 9.68099C1.8073 9.96323 2.03677 10.1927 2.31901 10.3365C2.63988 10.5 3.05992 10.5 3.9 10.5H7.1C7.94008 10.5 8.36012 10.5 8.68099 10.3365C8.96323 10.1927 9.1927 9.96323 9.33651 9.68099C9.5 9.36012 9.5 8.94008 9.5 8.1V7",
|
||||
"stroke": "currentColor",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "LinkExternal01"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import * as React from 'react'
|
||||
import data from './LinkExternal01.json'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
|
||||
|
||||
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
|
||||
props,
|
||||
ref,
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />)
|
||||
|
||||
export default Icon
|
||||
@@ -5,6 +5,7 @@ export { default as Edit03 } from './Edit03'
|
||||
export { default as Hash02 } from './Hash02'
|
||||
export { default as HelpCircle } from './HelpCircle'
|
||||
export { default as InfoCircle } from './InfoCircle'
|
||||
export { default as LinkExternal01 } from './LinkExternal01'
|
||||
export { default as LinkExternal02 } from './LinkExternal02'
|
||||
export { default as Loading02 } from './Loading02'
|
||||
export { default as LogOut01 } from './LogOut01'
|
||||
|
||||
@@ -18,6 +18,7 @@ import Tooltip from '@/app/components/base/tooltip'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import I18n from '@/context/i18n'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
type ISecretKeyModalProps = {
|
||||
isShow: boolean
|
||||
@@ -31,6 +32,7 @@ const SecretKeyModal = ({
|
||||
onClose,
|
||||
}: ISecretKeyModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { currentWorkspace, isCurrentWorkspaceManager } = useAppContext()
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [isVisible, setVisible] = useState(false)
|
||||
const [newKey, setNewKey] = useState<CreateApiKeyResponse | undefined>(undefined)
|
||||
@@ -118,11 +120,13 @@ const SecretKeyModal = ({
|
||||
setCopyValue(api.token)
|
||||
}}></div>
|
||||
</Tooltip>
|
||||
<div className={`flex items-center justify-center flex-shrink-0 w-6 h-6 rounded-lg cursor-pointer ${s.trashIcon}`} onClick={() => {
|
||||
setDelKeyId(api.id)
|
||||
setShowConfirmDelete(true)
|
||||
}}>
|
||||
</div>
|
||||
{ isCurrentWorkspaceManager
|
||||
&& <div className={`flex items-center justify-center flex-shrink-0 w-6 h-6 rounded-lg cursor-pointer ${s.trashIcon}`} onClick={() => {
|
||||
setDelKeyId(api.id)
|
||||
setShowConfirmDelete(true)
|
||||
}}>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -131,9 +135,7 @@ const SecretKeyModal = ({
|
||||
)
|
||||
}
|
||||
<div className='flex'>
|
||||
<Button type='default' className={`flex flex-shrink-0 mt-4 ${s.autoWidth}`} onClick={() =>
|
||||
onCreate()
|
||||
}>
|
||||
<Button type='default' className={`flex flex-shrink-0 mt-4 ${s.autoWidth}`} onClick={onCreate} disabled={ !currentWorkspace || currentWorkspace.role === 'normal'}>
|
||||
<PlusIcon className='flex flex-shrink-0 w-4 h-4' />
|
||||
<div className='text-xs font-medium text-gray-800'>{t('appApi.apiKeyModal.createNewSecretKey')}</div>
|
||||
</Button>
|
||||
|
||||
@@ -165,7 +165,7 @@ For versatile conversational apps using a Q&A format, call the chat-messages API
|
||||
/>
|
||||
<Row>
|
||||
<Col>
|
||||
The first page returns the latest `limit` bar, which is in reverse order.
|
||||
The first page returns the latest `limit` bar, which is in reverse order. Load previous pages by passing the `first_id` of the last message on the current page to the `first_id` parameter of the next request.
|
||||
|
||||
### Query
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
|
||||
/>
|
||||
<Row>
|
||||
<Col>
|
||||
滚动加载形式返回历史聊天记录,第一页返回最新 `limit` 条,即:倒序返回。
|
||||
滚动加载形式返回历史聊天记录,第一页返回最新 `limit` 条,加载更多时,返回 `first_id` 之前的 `limit` 条。
|
||||
|
||||
### Query
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import s from './style.module.css'
|
||||
import NotionIcon from '@/app/components/base/notion-icon'
|
||||
import { apiPrefix } from '@/config'
|
||||
import type { DataSourceNotion as TDataSourceNotion } from '@/models/common'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
type DataSourceNotionProps = {
|
||||
workspaces: TDataSourceNotion[]
|
||||
@@ -16,6 +17,8 @@ const DataSourceNotion = ({
|
||||
workspaces,
|
||||
}: DataSourceNotionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
|
||||
const connected = !!workspaces.length
|
||||
|
||||
return (
|
||||
@@ -35,18 +38,25 @@ const DataSourceNotion = ({
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!connected
|
||||
connected
|
||||
? (
|
||||
<Link
|
||||
className='flex items-center ml-3 px-3 h-7 bg-white border border-gray-200 rounded-md text-xs font-medium text-gray-700 cursor-pointer'
|
||||
href={`${apiPrefix}/oauth/data-source/notion`}>
|
||||
className={
|
||||
`flex items-center ml-3 px-3 h-7 bg-white border border-gray-200
|
||||
rounded-md text-xs font-medium text-gray-700
|
||||
${isCurrentWorkspaceManager ? 'cursor-pointer' : 'grayscale opacity-50 cursor-default'}`
|
||||
}
|
||||
href={isCurrentWorkspaceManager ? `${apiPrefix}/oauth/data-source/notion` : '/'}>
|
||||
{t('common.dataSource.connect')}
|
||||
</Link>
|
||||
)
|
||||
: (
|
||||
<Link
|
||||
href={`${apiPrefix}/oauth/data-source/notion`}
|
||||
className='flex items-center px-3 h-7 bg-white border-[0.5px] border-gray-200 text-xs font-medium text-primary-600 rounded-md cursor-pointer'>
|
||||
href={isCurrentWorkspaceManager ? `${apiPrefix}/oauth/data-source/notion` : '/' }
|
||||
className={
|
||||
`flex items-center px-3 h-7 bg-white border-[0.5px] border-gray-200 text-xs font-medium text-primary-600 rounded-md
|
||||
${isCurrentWorkspaceManager ? 'cursor-pointer' : 'grayscale opacity-50 cursor-default'}`
|
||||
}>
|
||||
<PlusIcon className='w-[14px] h-[14px] mr-[5px]' />
|
||||
{t('common.dataSource.notion.addWorkspace')}
|
||||
</Link>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.modal {
|
||||
max-width: 1024px !important;
|
||||
border-radius: 12px !important;
|
||||
margin-top: 60px;
|
||||
margin-bottom: 60px;
|
||||
padding: 0 !important;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { Status } from './declarations'
|
||||
type OperateProps = {
|
||||
isOpen: boolean
|
||||
status: Status
|
||||
disabled?: boolean
|
||||
onCancel: () => void
|
||||
onSave: () => void
|
||||
onAdd: () => void
|
||||
@@ -14,6 +15,7 @@ type OperateProps = {
|
||||
const Operate = ({
|
||||
isOpen,
|
||||
status,
|
||||
disabled,
|
||||
onCancel,
|
||||
onSave,
|
||||
onAdd,
|
||||
@@ -44,10 +46,10 @@ const Operate = ({
|
||||
|
||||
if (status === 'add') {
|
||||
return (
|
||||
<div className='
|
||||
px-3 h-[28px] bg-white border border-gray-200 rounded-md cursor-pointer
|
||||
text-xs font-medium text-gray-700 flex items-center
|
||||
' onClick={onAdd}>
|
||||
<div className={
|
||||
`px-3 h-[28px] bg-white border border-gray-200 rounded-md cursor-pointer
|
||||
text-xs font-medium text-gray-700 flex items-center ${disabled && 'opacity-50 cursor-default'}}`
|
||||
} onClick={() => !disabled && onAdd()}>
|
||||
{t('common.provider.addKey')}
|
||||
</div>
|
||||
)
|
||||
@@ -69,10 +71,10 @@ const Operate = ({
|
||||
<Indicator color='green' className='mr-4' />
|
||||
)
|
||||
}
|
||||
<div className='
|
||||
px-3 h-[28px] bg-white border border-gray-200 rounded-md cursor-pointer
|
||||
text-xs font-medium text-gray-700 flex items-center
|
||||
' onClick={onEdit}>
|
||||
<div className={
|
||||
`px-3 h-[28px] bg-white border border-gray-200 rounded-md cursor-pointer
|
||||
text-xs font-medium text-gray-700 flex items-center ${disabled && 'opacity-50 cursor-default'}}`
|
||||
} onClick={() => !disabled && onEdit()}>
|
||||
{t('common.provider.editKey')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ export type KeyValidatorProps = {
|
||||
forms: Form[]
|
||||
keyFrom: KeyFrom
|
||||
onSave: (v: ValidateValue) => Promise<boolean | undefined>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const KeyValidator = ({
|
||||
@@ -22,6 +23,7 @@ const KeyValidator = ({
|
||||
forms,
|
||||
keyFrom,
|
||||
onSave,
|
||||
disabled,
|
||||
}: KeyValidatorProps) => {
|
||||
const triggerKey = `plugins/${type}`
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
@@ -85,10 +87,11 @@ const KeyValidator = ({
|
||||
onSave={handleSave}
|
||||
onAdd={handleAdd}
|
||||
onEdit={handleEdit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
isOpen && (
|
||||
isOpen && !disabled && (
|
||||
<div className='px-4 py-3'>
|
||||
{
|
||||
forms.map(form => (
|
||||
|
||||
@@ -16,9 +16,9 @@ import { fetchMembers } from '@/service/common'
|
||||
import I18n from '@/context/i18n'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import { useWorkspacesContext } from '@/context/workspace-context'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const MembersPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const RoleMap = {
|
||||
@@ -27,15 +27,13 @@ const MembersPage = () => {
|
||||
normal: t('common.members.normal'),
|
||||
}
|
||||
const { locale } = useContext(I18n)
|
||||
const { userProfile } = useAppContext()
|
||||
const { userProfile, currentWorkspace, isCurrentWorkspaceManager } = 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
|
||||
const { workspaces } = useWorkspacesContext()
|
||||
const currentWrokspace = workspaces.filter(item => item.current)?.[0]
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -43,14 +41,14 @@ const MembersPage = () => {
|
||||
<div className='flex items-center mb-4 p-3 bg-gray-50 rounded-2xl'>
|
||||
<div className={cn(s['logo-icon'], 'shrink-0')}></div>
|
||||
<div className='grow mx-2'>
|
||||
<div className='text-sm font-medium text-gray-900'>{currentWrokspace?.name}</div>
|
||||
<div className='text-sm font-medium text-gray-900'>{currentWorkspace?.name}</div>
|
||||
<div className='text-xs text-gray-500'>{t('common.userProfile.workspace')}</div>
|
||||
</div>
|
||||
<div className='
|
||||
shrink-0 flex items-center py-[7px] px-3 border-[0.5px] border-gray-200
|
||||
<div className={
|
||||
`shrink-0 flex items-center py-[7px] px-3 border-[0.5px] border-gray-200
|
||||
text-[13px] font-medium text-primary-600 bg-white
|
||||
shadow-xs rounded-lg cursor-pointer
|
||||
' onClick={() => setInviteModalVisible(true)}>
|
||||
shadow-xs rounded-lg ${isCurrentWorkspaceManager ? 'cursor-pointer' : 'grayscale opacity-50 cursor-default'}`
|
||||
} onClick={() => isCurrentWorkspaceManager && setInviteModalVisible(true)}>
|
||||
<UserPlusIcon className='w-4 h-4 mr-2 ' />
|
||||
{t('common.members.invite')}
|
||||
</div>
|
||||
|
||||
@@ -50,19 +50,6 @@ const config: ProviderConfig = {
|
||||
'zh-Hans': '在此输入您的 API ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
key: 'api_key',
|
||||
required: true,
|
||||
label: {
|
||||
'en': 'API Key',
|
||||
'zh-Hans': 'API Key',
|
||||
},
|
||||
placeholder: {
|
||||
'en': 'Enter your API key here',
|
||||
'zh-Hans': '在此输入您的 API Key',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
key: 'api_secret',
|
||||
@@ -76,6 +63,19 @@ const config: ProviderConfig = {
|
||||
'zh-Hans': '在此输入您的 API Secret',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
key: 'api_key',
|
||||
required: true,
|
||||
label: {
|
||||
'en': 'API Key',
|
||||
'zh-Hans': 'API Key',
|
||||
},
|
||||
placeholder: {
|
||||
'en': 'Enter your API key here',
|
||||
'zh-Hans': '在此输入您的 API Key',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ export type BackendModel = {
|
||||
model_provider: {
|
||||
provider_name: ProviderEnum
|
||||
provider_type: PreferredProviderTypeEnum
|
||||
quota_type: 'trial' | 'paid'
|
||||
quota_unit: 'times' | 'tokens'
|
||||
quota_used: number
|
||||
quota_limit: number
|
||||
}
|
||||
features: ModelFeature[]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import ModelModal from './model-modal'
|
||||
import config from './configs'
|
||||
import { ConfigurableProviders } from './utils'
|
||||
import { ChevronDownDouble } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
// import { HelpCircle } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { HelpCircle } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import {
|
||||
changeModelProviderPriority,
|
||||
deleteModelProvider,
|
||||
@@ -29,6 +29,7 @@ import Confirm from '@/app/components/base/confirm/common'
|
||||
import { ModelType } from '@/app/components/header/account-setting/model-page/declarations'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
const MODEL_CARD_LIST = [
|
||||
config.openai,
|
||||
@@ -205,7 +206,14 @@ const ModelPage = () => {
|
||||
<div className='w-full'>
|
||||
<div className={titleClassName}>
|
||||
{t('common.modelProvider.systemReasoningModel.key')}
|
||||
{/* <HelpCircle className={tipClassName} /> */}
|
||||
<Tooltip
|
||||
selector='model-page-system-reasoning-model-tip'
|
||||
htmlContent={
|
||||
<div className='w-[261px] text-gray-500'>{t('common.modelProvider.systemReasoningModel.tip')}</div>
|
||||
}
|
||||
>
|
||||
<HelpCircle className={tipClassName} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div>
|
||||
<ModelSelector
|
||||
@@ -218,7 +226,14 @@ const ModelPage = () => {
|
||||
<div className='w-full'>
|
||||
<div className={titleClassName}>
|
||||
{t('common.modelProvider.embeddingModel.key')}
|
||||
{/* <HelpCircle className={tipClassName} /> */}
|
||||
<Tooltip
|
||||
selector='model-page-system-embedding-model-tip'
|
||||
htmlContent={
|
||||
<div className='w-[261px] text-gray-500'>{t('common.modelProvider.embeddingModel.tip')}</div>
|
||||
}
|
||||
>
|
||||
<HelpCircle className={tipClassName} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div>
|
||||
<ModelSelector
|
||||
@@ -231,7 +246,14 @@ const ModelPage = () => {
|
||||
<div className='w-full'>
|
||||
<div className={titleClassName}>
|
||||
{t('common.modelProvider.speechToTextModel.key')}
|
||||
{/* <HelpCircle className={tipClassName} /> */}
|
||||
<Tooltip
|
||||
selector='model-page-system-speechToText-model-tip'
|
||||
htmlContent={
|
||||
<div className='w-[261px] text-gray-500'>{t('common.modelProvider.speechToTextModel.tip')}</div>
|
||||
}
|
||||
>
|
||||
<HelpCircle className={tipClassName} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div>
|
||||
<ModelSelector
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import type { ProviderConfigItem, TypeWithI18N } from '../declarations'
|
||||
import { ProviderEnum as ProviderEnumValue } from '../declarations'
|
||||
import s from './index.module.css'
|
||||
import I18n from '@/context/i18n'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { submitFreeQuota } from '@/service/common'
|
||||
import { LinkExternal01 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
|
||||
const TIP_MAP: { [k: string]: TypeWithI18N } = {
|
||||
[ProviderEnumValue.minimax]: {
|
||||
'en': 'Earn 1 million tokens for free',
|
||||
'zh-Hans': '免费获取 100 万个 token',
|
||||
},
|
||||
[ProviderEnumValue.spark]: {
|
||||
'en': 'Earn 3 million tokens for free',
|
||||
'zh-Hans': '免费获取 300 万个 token',
|
||||
},
|
||||
}
|
||||
type FreeQuotaProps = {
|
||||
modelItem: ProviderConfigItem
|
||||
onUpdate: () => void
|
||||
}
|
||||
const FreeQuota: FC<FreeQuotaProps> = ({
|
||||
modelItem,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const { locale } = useContext(I18n)
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await submitFreeQuota(`/workspaces/current/model-providers/${modelItem.key}/free-quota-submit`)
|
||||
|
||||
if (res.type === 'redirect' && res.redirect_url)
|
||||
window.location.href = res.redirect_url
|
||||
else if (res.type === 'submit' && res.result === 'success')
|
||||
onUpdate()
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
📣
|
||||
<div className={`${s.vender} ml-1 text-xs font-medium text-transparent`}>{TIP_MAP[modelItem.key][locale]}</div>
|
||||
<div className='mx-1 text-xs font-medium text-gray-400'>·</div>
|
||||
<a
|
||||
href='https://docs.dify.ai/v/zh-hans/getting-started/faq/llms-use-faq#8.-ru-he-mian-fei-shen-ling-xun-fei-xing-huo-minimax-mo-xing-de-ti-yanedu'
|
||||
target='_blank'
|
||||
className='flex items-center text-xs font-medium text-[#155EEF]'>
|
||||
{t('common.modelProvider.freeQuota.howToEarn')}
|
||||
<LinkExternal01 className='ml-0.5 w-3 h-3' />
|
||||
</a>
|
||||
<Button
|
||||
type='primary'
|
||||
className='ml-3 !px-3 !h-7 !rounded-md !text-xs !font-medium'
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('common.operation.getForFree')}
|
||||
</Button>
|
||||
<div className='mx-2 w-[1px] h-4 bg-black/5' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FreeQuota
|
||||
@@ -2,8 +2,10 @@ import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import type { FormValue, Provider, ProviderConfigItem, ProviderWithConfig, ProviderWithQuota } from '../declarations'
|
||||
import { ProviderEnum } from '../declarations'
|
||||
import Indicator from '../../../indicator'
|
||||
import Selector from '../selector'
|
||||
import FreeQuota from './FreeQuota'
|
||||
import I18n from '@/context/i18n'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
@@ -13,6 +15,7 @@ type SettingProps = {
|
||||
modelItem: ProviderConfigItem
|
||||
onOpenModal: (v?: FormValue) => void
|
||||
onOperate: (v: Record<string, any>) => void
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
const Setting: FC<SettingProps> = ({
|
||||
@@ -20,6 +23,7 @@ const Setting: FC<SettingProps> = ({
|
||||
modelItem,
|
||||
onOpenModal,
|
||||
onOperate,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const { locale } = useContext(I18n)
|
||||
const { t } = useTranslation()
|
||||
@@ -29,6 +33,14 @@ const Setting: FC<SettingProps> = ({
|
||||
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
{
|
||||
(modelItem.key === ProviderEnum.minimax || modelItem.key === ProviderEnum.spark) && systemFree && !systemFree?.is_valid && !IS_CE_EDITION && locale === 'zh-Hans' && (
|
||||
<FreeQuota
|
||||
modelItem={modelItem}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
modelItem.disable && !IS_CE_EDITION && (
|
||||
<div className='flex items-center text-xs text-gray-500'>
|
||||
|
||||
@@ -26,6 +26,7 @@ const ModelItem: FC<ModelItemProps> = ({
|
||||
modelItem,
|
||||
onOpenModal,
|
||||
onOperate,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const { locale } = useContext(I18n)
|
||||
const custom = currentProvider?.providers.find(p => p.provider_type === 'custom') as ProviderWithModels
|
||||
@@ -47,6 +48,7 @@ const ModelItem: FC<ModelItemProps> = ({
|
||||
modelItem={modelItem}
|
||||
onOpenModal={onOpenModal}
|
||||
onOperate={onOperate}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Form, ValidateValue } from '../key-validator/declarations'
|
||||
import { updatePluginKey, validatePluginKey } from './utils'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import type { PluginProvider } from '@/models/common'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
type SerpapiPluginProps = {
|
||||
plugin: PluginProvider
|
||||
@@ -16,6 +17,7 @@ const SerpapiPlugin = ({
|
||||
onUpdate,
|
||||
}: SerpapiPluginProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const { notify } = useToastContext()
|
||||
|
||||
const forms: Form[] = [{
|
||||
@@ -70,6 +72,7 @@ const SerpapiPlugin = ({
|
||||
link: 'https://serpapi.com/manage-api-key',
|
||||
}}
|
||||
onSave={handleSave}
|
||||
disabled={!isCurrentWorkspaceManager}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import Indicator from '../indicator'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import NewAppDialog from '@/app/(commonLayout)/apps/NewAppDialog'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
type IAppSelectorProps = {
|
||||
appItems: AppDetailResponse[]
|
||||
@@ -16,6 +17,7 @@ type IAppSelectorProps = {
|
||||
|
||||
export default function AppSelector({ appItems, curApp }: IAppSelectorProps) {
|
||||
const router = useRouter()
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const [showNewAppDialog, setShowNewAppDialog] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -77,7 +79,7 @@ export default function AppSelector({ appItems, curApp }: IAppSelectorProps) {
|
||||
))
|
||||
}
|
||||
</div>)}
|
||||
<Menu.Item>
|
||||
{isCurrentWorkspaceManager && <Menu.Item>
|
||||
<div className='p-1' onClick={() => setShowNewAppDialog(true)}>
|
||||
<div
|
||||
className='flex items-center h-12 rounded-lg cursor-pointer hover:bg-gray-100'
|
||||
@@ -95,6 +97,7 @@ export default function AppSelector({ appItems, curApp }: IAppSelectorProps) {
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
}
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import AccountDropdown from './account-dropdown'
|
||||
import AppNav from './app-nav'
|
||||
@@ -8,6 +10,7 @@ import GithubStar from './github-star'
|
||||
import PluginNav from './plugin-nav'
|
||||
import s from './index.module.css'
|
||||
import { WorkspaceProvider } from '@/context/workspace-context'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
const navClassName = `
|
||||
flex items-center relative mr-3 px-3 h-8 rounded-xl
|
||||
@@ -16,6 +19,7 @@ const navClassName = `
|
||||
`
|
||||
|
||||
const Header = () => {
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center'>
|
||||
@@ -29,7 +33,7 @@ const Header = () => {
|
||||
<ExploreNav className={navClassName} />
|
||||
<AppNav />
|
||||
<PluginNav className={navClassName} />
|
||||
<DatasetNav />
|
||||
{isCurrentWorkspaceManager && <DatasetNav />}
|
||||
</div>
|
||||
<div className='flex items-center flex-shrink-0'>
|
||||
<EnvNav />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'
|
||||
import { debounce } from 'lodash-es'
|
||||
import Indicator from '../../indicator'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
type NavItem = {
|
||||
id: string
|
||||
@@ -29,6 +30,7 @@ const itemClassName = `
|
||||
|
||||
const NavSelector = ({ curNav, navs, createText, onCreate, onLoadmore }: INavSelectorProps) => {
|
||||
const router = useRouter()
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
|
||||
const handleScroll = useCallback(debounce((e) => {
|
||||
if (typeof onLoadmore === 'function') {
|
||||
@@ -81,7 +83,7 @@ const NavSelector = ({ curNav, navs, createText, onCreate, onLoadmore }: INavSel
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<Menu.Item>
|
||||
{isCurrentWorkspaceManager && <Menu.Item>
|
||||
<div className='p-1' onClick={onCreate}>
|
||||
<div
|
||||
className='flex items-center h-12 rounded-lg cursor-pointer hover:bg-gray-100'
|
||||
@@ -98,7 +100,7 @@ const NavSelector = ({ curNav, navs, createText, onCreate, onLoadmore }: INavSel
|
||||
<div className='font-normal text-[14px] text-gray-700'>{createText}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</Menu.Item>}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { createRef, useEffect, useRef, useState } from 'react'
|
||||
import { createRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { createContext, useContext, useContextSelector } from 'use-context-selector'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { fetchAppList } from '@/service/apps'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { fetchLanggeniusVersion, fetchUserProfile } from '@/service/common'
|
||||
import { fetchCurrentWorkspace, fetchLanggeniusVersion, fetchUserProfile } from '@/service/common'
|
||||
import type { App } from '@/types/app'
|
||||
import type { LangGeniusVersionResponse, UserProfileResponse } from '@/models/common'
|
||||
import type { ICurrentWorkspace, LangGeniusVersionResponse, UserProfileResponse } from '@/models/common'
|
||||
|
||||
export type AppContextValue = {
|
||||
apps: App[]
|
||||
mutateApps: () => void
|
||||
mutateApps: VoidFunction
|
||||
userProfile: UserProfileResponse
|
||||
mutateUserProfile: () => void
|
||||
mutateUserProfile: VoidFunction
|
||||
currentWorkspace: ICurrentWorkspace
|
||||
isCurrentWorkspaceManager: boolean
|
||||
mutateCurrentWorkspace: VoidFunction
|
||||
pageContainerRef: React.RefObject<HTMLDivElement>
|
||||
langeniusVersionInfo: LangGeniusVersionResponse
|
||||
useSelector: typeof useSelector
|
||||
@@ -30,6 +33,17 @@ const initialLangeniusVersionInfo = {
|
||||
can_auto_update: false,
|
||||
}
|
||||
|
||||
const initialWorkspaceInfo: ICurrentWorkspace = {
|
||||
id: '',
|
||||
name: '',
|
||||
plan: '',
|
||||
status: '',
|
||||
created_at: 0,
|
||||
role: 'normal',
|
||||
providers: [],
|
||||
in_trail: true,
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppContextValue>({
|
||||
apps: [],
|
||||
mutateApps: () => { },
|
||||
@@ -40,7 +54,10 @@ const AppContext = createContext<AppContextValue>({
|
||||
avatar: '',
|
||||
is_password_set: false,
|
||||
},
|
||||
currentWorkspace: initialWorkspaceInfo,
|
||||
isCurrentWorkspaceManager: false,
|
||||
mutateUserProfile: () => { },
|
||||
mutateCurrentWorkspace: () => { },
|
||||
pageContainerRef: createRef(),
|
||||
langeniusVersionInfo: initialLangeniusVersionInfo,
|
||||
useSelector,
|
||||
@@ -59,10 +76,14 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
|
||||
|
||||
const { data: appList, mutate: mutateApps } = useSWR({ url: '/apps', params: { page: 1 } }, fetchAppList)
|
||||
const { data: userProfileResponse, mutate: mutateUserProfile } = useSWR({ url: '/account/profile', params: {} }, fetchUserProfile)
|
||||
const { data: currentWorkspaceResponse, mutate: mutateCurrentWorkspace } = useSWR({ url: '/workspaces/current', params: {} }, fetchCurrentWorkspace)
|
||||
|
||||
const [userProfile, setUserProfile] = useState<UserProfileResponse>()
|
||||
const [langeniusVersionInfo, setLangeniusVersionInfo] = useState<LangGeniusVersionResponse>(initialLangeniusVersionInfo)
|
||||
const updateUserProfileAndVersion = async () => {
|
||||
const [currentWorkspace, setCurrentWorkspace] = useState<ICurrentWorkspace>(initialWorkspaceInfo)
|
||||
const isCurrentWorkspaceManager = useMemo(() => ['owner', 'admin'].includes(currentWorkspace.role), [currentWorkspace.role])
|
||||
|
||||
const updateUserProfileAndVersion = useCallback(async () => {
|
||||
if (userProfileResponse && !userProfileResponse.bodyUsed) {
|
||||
const result = await userProfileResponse.json()
|
||||
setUserProfile(result)
|
||||
@@ -71,16 +92,33 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
|
||||
const versionData = await fetchLanggeniusVersion({ url: '/version', params: { current_version } })
|
||||
setLangeniusVersionInfo({ ...versionData, current_version, latest_version: versionData.version, current_env })
|
||||
}
|
||||
}
|
||||
}, [userProfileResponse])
|
||||
|
||||
useEffect(() => {
|
||||
updateUserProfileAndVersion()
|
||||
}, [userProfileResponse])
|
||||
}, [updateUserProfileAndVersion, userProfileResponse])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentWorkspaceResponse)
|
||||
setCurrentWorkspace(currentWorkspaceResponse)
|
||||
}, [currentWorkspaceResponse])
|
||||
|
||||
if (!appList || !userProfile)
|
||||
return <Loading type='app' />
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{ apps: appList.data, mutateApps, userProfile, mutateUserProfile, pageContainerRef, langeniusVersionInfo, useSelector }}>
|
||||
<AppContext.Provider value={{
|
||||
apps: appList.data,
|
||||
mutateApps,
|
||||
userProfile,
|
||||
mutateUserProfile,
|
||||
pageContainerRef,
|
||||
langeniusVersionInfo,
|
||||
useSelector,
|
||||
currentWorkspace,
|
||||
isCurrentWorkspaceManager,
|
||||
mutateCurrentWorkspace,
|
||||
}}>
|
||||
<div ref={pageContainerRef} className='relative flex flex-col h-full overflow-auto bg-gray-100'>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -121,6 +121,7 @@ const translation = {
|
||||
tooLong: 'Variable key: {{key}} too length. Can not be longer then 16 characters',
|
||||
notValid: 'Variable key: {{key}} is invalid. Can only contain letters, numbers, and underscores',
|
||||
notStartWithNumber: 'Variable key: {{key}} can not start with a number',
|
||||
keyAlreadyExists:'Variable key: :{{key}} already exists',
|
||||
},
|
||||
variableConig: {
|
||||
modalTitle: 'Field settings',
|
||||
|
||||
@@ -117,6 +117,7 @@ const translation = {
|
||||
tooLong: '变量: {{key}} 长度太长。不能超过 16 个字符',
|
||||
notValid: '变量: {{key}} 非法。只能包含英文字符,数字和下划线',
|
||||
notStartWithNumber: '变量: {{key}} 不能以数字开头',
|
||||
keyAlreadyExists:'变量:{{key}} 已存在',
|
||||
},
|
||||
variableConig: {
|
||||
modalTitle: '变量设置',
|
||||
|
||||
@@ -23,6 +23,7 @@ const translation = {
|
||||
},
|
||||
},
|
||||
callTimes: 'Call times',
|
||||
usedToken: 'Used token',
|
||||
setAPIBtn: 'Go to setup model provider',
|
||||
tryCloud: 'Or try the cloud version of Dify with free quote',
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ const translation = {
|
||||
apiKeyInfo: {
|
||||
cloud: {
|
||||
trial: {
|
||||
title: '您正在使用 {{providerName}} 试用配额。',
|
||||
title: '您正在使用 {{providerName}} 的试用配额。',
|
||||
description: '试用配额仅供您测试使用。 在试用配额用完之前,请自行设置模型提供商或购买额外配额。',
|
||||
},
|
||||
exhausted: {
|
||||
@@ -23,6 +23,7 @@ const translation = {
|
||||
},
|
||||
},
|
||||
callTimes: '调用次数',
|
||||
usedToken: '使用 Tokens',
|
||||
setAPIBtn: '设置模型提供商',
|
||||
tryCloud: '或者尝试使用 Dify 的云版本并使用试用配额',
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ const translation = {
|
||||
download: 'Download',
|
||||
setup: 'Setup',
|
||||
getForFree: 'Get for free',
|
||||
reload: 'Reload',
|
||||
},
|
||||
placeholder: {
|
||||
input: 'Please enter',
|
||||
@@ -209,15 +210,15 @@ const translation = {
|
||||
setupModelFirst: 'Please set up your model first',
|
||||
systemReasoningModel: {
|
||||
key: 'System Reasoning Model',
|
||||
tip: 'System Reasoning Model',
|
||||
tip: 'Set the default inference model to be used for creating applications, as well as features such as dialogue name generation and next question suggestion will also use the default inference model.',
|
||||
},
|
||||
embeddingModel: {
|
||||
key: 'Embedding Model',
|
||||
tip: 'Embedding Model',
|
||||
tip: 'Set the default model for document embedding processing of the dataset, both retrieval and import of the dataset use this Embedding model for vectorization processing. Switching will cause the vector dimension between the imported dataset and the question to be inconsistent, resulting in retrieval failure. To avoid retrieval failure, please do not switch this model at will.',
|
||||
},
|
||||
speechToTextModel: {
|
||||
key: 'Speech-to-Text Model',
|
||||
tip: 'Speech-to-Text Model',
|
||||
tip: 'Set the default model for speech-to-text input in conversation.',
|
||||
},
|
||||
quota: 'Quota',
|
||||
searchModel: 'Search model',
|
||||
@@ -249,6 +250,9 @@ const translation = {
|
||||
front: 'Your API KEY will be encrypted and stored using',
|
||||
back: ' technology.',
|
||||
},
|
||||
freeQuota: {
|
||||
howToEarn: 'How to earn',
|
||||
},
|
||||
},
|
||||
dataSource: {
|
||||
add: 'Add a data source',
|
||||
|
||||
@@ -25,6 +25,7 @@ const translation = {
|
||||
download: '下载',
|
||||
setup: '设置',
|
||||
getForFree: '免费获取',
|
||||
reload: '刷新',
|
||||
},
|
||||
placeholder: {
|
||||
input: '请输入',
|
||||
@@ -209,15 +210,15 @@ const translation = {
|
||||
setupModelFirst: '请先设置您的模型',
|
||||
systemReasoningModel: {
|
||||
key: '系统推理模型',
|
||||
tip: '系统推理模型',
|
||||
tip: '设置创建应用使用的默认推理模型,以及对话名称生成、下一步问题建议等功能也会使用该默认推理模型。',
|
||||
},
|
||||
embeddingModel: {
|
||||
key: 'Embedding 模型',
|
||||
tip: 'Embedding 模型',
|
||||
tip: '设置数据集文档嵌入处理的默认模型,检索和导入数据集均使用该Embedding模型进行向量化处理,切换后将导致已导入的数据集与问题之间的向量维度不一致,从而导致检索失败。为避免检索失败,请勿随意切换该模型。',
|
||||
},
|
||||
speechToTextModel: {
|
||||
key: '语音转文本模型',
|
||||
tip: '语音转文本模型',
|
||||
tip: '设置对话中语音转文字输入的默认使用模型。',
|
||||
},
|
||||
quota: '额度',
|
||||
searchModel: '搜索模型',
|
||||
@@ -249,6 +250,9 @@ const translation = {
|
||||
front: '您的密钥将使用',
|
||||
back: '技术进行加密和存储。',
|
||||
},
|
||||
freeQuota: {
|
||||
howToEarn: '如何获取',
|
||||
},
|
||||
},
|
||||
dataSource: {
|
||||
add: '添加数据源',
|
||||
|
||||
@@ -118,6 +118,13 @@ export type IWorkspace = {
|
||||
current: boolean
|
||||
}
|
||||
|
||||
export type ICurrentWorkspace = Omit<IWorkspace, 'current'> & {
|
||||
role: 'normal' | 'admin' | 'owner'
|
||||
providers: Provider[]
|
||||
in_trail: boolean
|
||||
trial_end_reason?: string
|
||||
}
|
||||
|
||||
export type DataSourceNotionPage = {
|
||||
page_icon: null | {
|
||||
type: string | null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dify-web",
|
||||
"version": "0.3.12",
|
||||
"version": "0.3.13",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Fetcher } from 'swr'
|
||||
import { del, get, patch, post, put } from './base'
|
||||
import type {
|
||||
AccountIntegrate, CommonResponse, DataSourceNotion,
|
||||
ICurrentWorkspace,
|
||||
IWorkspace, LangGeniusVersionResponse, Member,
|
||||
OauthResponse, PluginProvider, Provider, ProviderAnthropicToken, ProviderAzureToken,
|
||||
SetupStatusResponse, TenantInfoResponse, UserProfileOriginResponse,
|
||||
@@ -87,6 +88,10 @@ export const fetchFilePreview: Fetcher<{ content: string }, { fileID: string }>
|
||||
return get(`/files/${fileID}/preview`) as Promise<{ content: string }>
|
||||
}
|
||||
|
||||
export const fetchCurrentWorkspace: Fetcher<ICurrentWorkspace, { url: string; params: Record<string, any> }> = ({ url, params }) => {
|
||||
return get(url, { params }) as Promise<ICurrentWorkspace>
|
||||
}
|
||||
|
||||
export const fetchWorkspaces: Fetcher<{ workspaces: IWorkspace[] }, { url: string; params: Record<string, any> }> = ({ url, params }) => {
|
||||
return get(url, { params }) as Promise<{ workspaces: IWorkspace[] }>
|
||||
}
|
||||
@@ -169,3 +174,7 @@ export const fetchDefaultModal: Fetcher<BackendModel, string> = (url) => {
|
||||
export const updateDefaultModel: Fetcher<CommonResponse, { url: string; body: any }> = ({ url, body }) => {
|
||||
return post(url, { body }) as Promise<CommonResponse>
|
||||
}
|
||||
|
||||
export const submitFreeQuota: Fetcher<{ type: string; redirect_url?: string; result?: string }, string> = (url) => {
|
||||
return post(url) as Promise<{ type: string; redirect_url?: string; result?: string }>
|
||||
}
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
/*
|
||||
* Formats a number with comma separators.
|
||||
* Formats a number with comma separators.
|
||||
formatNumber(1234567) will return '1,234,567'
|
||||
formatNumber(1234567.89) will return '1,234,567.89'
|
||||
*/
|
||||
export const formatNumber = (num: number | string) => {
|
||||
if (!num) return num;
|
||||
let parts = num.toString().split(".");
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
return parts.join(".");
|
||||
if (!num)
|
||||
return num
|
||||
const parts = num.toString().split('.')
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
return parts.join('.')
|
||||
}
|
||||
|
||||
export const formatFileSize = (num: number) => {
|
||||
if (!num) return num;
|
||||
const units = ['', 'K', 'M', 'G', 'T', 'P'];
|
||||
let index = 0;
|
||||
if (!num)
|
||||
return num
|
||||
const units = ['', 'K', 'M', 'G', 'T', 'P']
|
||||
let index = 0
|
||||
while (num >= 1024 && index < units.length) {
|
||||
num = num / 1024;
|
||||
index++;
|
||||
num = num / 1024
|
||||
index++
|
||||
}
|
||||
return num.toFixed(2) + `${units[index]}B`;
|
||||
return `${num.toFixed(2)}${units[index]}B`
|
||||
}
|
||||
|
||||
export const formatTime = (num: number) => {
|
||||
if (!num) return num;
|
||||
const units = ['sec', 'min', 'h'];
|
||||
let index = 0;
|
||||
if (!num)
|
||||
return num
|
||||
const units = ['sec', 'min', 'h']
|
||||
let index = 0
|
||||
while (num >= 60 && index < units.length) {
|
||||
num = num / 60;
|
||||
index++;
|
||||
num = num / 60
|
||||
index++
|
||||
}
|
||||
return `${num.toFixed(2)} ${units[index]}`;
|
||||
return `${num.toFixed(2)} ${units[index]}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user