Compare commits

...

27 Commits

Author SHA1 Message Date
John Wang
6c3ff02d58 fix: last used json parse 2023-08-17 16:10:54 +08:00
John Wang
5f2fab31be fix: provider last update not working 2023-08-17 15:53:32 +08:00
John Wang
66c6679637 feat: optimize claude batch process command 2023-08-17 12:09:51 +08:00
John Wang
bc5eb3fbd2 feat: change stripe callback url params 2023-08-17 11:55:51 +08:00
John Wang
f9e913694b feat: add paid_max_quantity 2023-08-16 14:50:25 +08:00
John Wang
d1b82bb8e3 feat: support stripe checkout quantity 2023-08-15 23:22:40 +08:00
John Wang
dee004bffd feat: add PAID_MIN_QUALITY 2023-08-15 22:44:48 +08:00
takatost
e8c14bb732 feat: rename title in site both rename name in app (#857) 2023-08-15 20:42:32 +08:00
Joel
bf45f08e78 chore: handle provider name capitalization (#855) 2023-08-15 17:22:40 +08:00
Matri
2c77a74c40 fix: frontend permission check (#784) 2023-08-15 13:35:47 +08:00
zxhlyh
440cf63317 fix: setting modal margin (#849) 2023-08-15 12:05:27 +08:00
Matri
50b11e925b fix: change config string variable limit (#837)
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2023-08-15 11:26:58 +08:00
Joel
7cc81b4269 fix: var config content can not be saved (#841) 2023-08-15 09:51:43 +08:00
crazywoola
93b0813b73 Update README.md (#839) 2023-08-15 09:43:21 +08:00
crazywoola
649b44aefa Update README_CN.md (#840) 2023-08-15 09:43:11 +08:00
crazywoola
1e95d74ae2 update doc (#838) 2023-08-15 09:25:37 +08:00
crazywoola
700d5f2673 update llms (#835) 2023-08-14 22:41:40 +08:00
takatost
3b8234e486 feat: bump version to 0.3.13 (#830) 2023-08-14 16:36:49 +08:00
zxhlyh
0feb0bf7c0 fix: free quota tip (#831) 2023-08-14 16:36:04 +08:00
Krasus.Chen
c5d148bf94 fix #794 input bug (#801) 2023-08-14 15:29:18 +08:00
zxhlyh
e5e86fc033 Feat/apply free quota (#828)
Co-authored-by: Joel <iamjoel007@gmail.com>
2023-08-14 12:46:28 +08:00
takatost
cc52cdc2a9 Feat/add free provider apply (#829) 2023-08-14 12:44:35 +08:00
zxhlyh
42a417167f feat: add system default model help tip (#827) 2023-08-13 22:50:31 +08:00
crazywoola
4b0d9272ef Fix 802 (#826) 2023-08-13 20:30:17 +08:00
crazywoola
48a303b8e9 Feature/fix disable site (#825) 2023-08-13 17:32:23 +08:00
takatost
8e15ba6cd6 Fix/no trial provider (#823) 2023-08-13 14:56:32 +08:00
takatost
7898937eae feat: optimize message return (#822) 2023-08-13 13:51:12 +08:00
70 changed files with 740 additions and 277 deletions

View File

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

View File

@@ -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
### 快速启动

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -165,7 +165,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
/>
<Row>
<Col>
滚动加载形式返回历史聊天记录,第一页返回最新 `limit` 条,即:倒序返回
滚动加载形式返回历史聊天记录,第一页返回最新 `limit` 条,加载更多时,返回 `first_id` 之前的 `limit` 条
### Query

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -117,6 +117,7 @@ const translation = {
tooLong: '变量: {{key}} 长度太长。不能超过 16 个字符',
notValid: '变量: {{key}} 非法。只能包含英文字符,数字和下划线',
notStartWithNumber: '变量: {{key}} 不能以数字开头',
keyAlreadyExists:'变量:{{key}} 已存在',
},
variableConig: {
modalTitle: '变量设置',

View File

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

View File

@@ -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 的云版本并使用试用配额',
},

View File

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

View File

@@ -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: '添加数据源',

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "dify-web",
"version": "0.3.12",
"version": "0.3.13",
"private": true,
"scripts": {
"dev": "next dev",

View File

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

View File

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