Compare commits

...
Author SHA1 Message Date
yyh 41857cf9cd Fix retry handling for request bodies 2026-01-05 14:31:57 +08:00
yyh 78ab09b521 fix(nodejs-client): handle malformed JSON responses gracefully
Fix critical bugs in response body parsing:

1. Error responses (non-OK status):
   - Response body can only be read once
   - Previous code: response.json() fails → response.text() throws 'Body already read'
   - Fix: Read as text first, then parse JSON with fallback to raw text

2. Success responses (OK status):
   - Malformed JSON was not caught, causing NetworkError misclassification
   - Fix: Wrap JSON.parse in try-catch, fallback to raw text

These fixes prevent unhandled promise rejections and provide better error
messages when servers return malformed JSON responses.

Reported-by: gemini-code-assist
Severity: Critical (issue 1), High (issue 2)
2026-01-05 14:23:20 +08:00
yyh 249a491743 fix(nodejs-client): fix timeout handling in retry loop
Fix critical bug in retry loop timeout control:
- Move AbortController creation inside while loop to ensure each retry uses a fresh controller
- Previous implementation caused retries to use an already-aborted controller, making retries fail immediately
- Add eslint-disable comments to explain necessary type assertions
- Improve code comments explaining DOM ReadableStream type conversion

This fix ensures the retry mechanism works correctly with independent timeout control for each attempt.
2026-01-05 14:13:22 +08:00
yyh df67842fae fix(nodejs-client): fix all test mocks for fetch API
- Add createMockResponse helper to properly mock fetch responses
- Update all test cases to use the mock helper with all required methods
- Fix timeout error test to use AbortError instead of fake timers
- Ensure beforeEach restores real timers to avoid test interference
- All 85 tests now passing
2026-01-05 14:04:57 +08:00
yyh a4495ab586 chore(nodejs-client): update pnpm-lock.yaml to remove axios 2026-01-05 13:58:25 +08:00
yyh 7c3f15c507 chore(nodejs-client): bump version to 3.1.0 2026-01-05 13:57:45 +08:00
yyh 5db66ad033 refactor(nodejs-client): replace axios with native fetch API
- Replace axios with Node.js native fetch API for HTTP requests
- Update HttpClient to use fetch instead of axios instance
- Convert axios-specific error handling to fetch-based error mapping
- Update response type handling for streams, JSON, text, etc.
- Remove axios from package.json dependencies
- Update all test files to mock fetch instead of axios

This change reduces external dependencies and uses the built-in
fetch API available in Node.js 18+, which is already the minimum
required version for this SDK.
2026-01-05 13:57:22 +08:00
longbingljwandGitHub c58a093fd1 docs: update comments in docker/.env.example (#30516) 2026-01-04 21:52:03 +08:00
-LAN-andGitHub 06ba40f016 refactor(code_node): implement DI for the code node (#30519) 2026-01-04 21:50:42 +08:00
2b838077e0 fix: when first setup after auto login error (#30523)
Co-authored-by: maxin <maxin7@xiaomi.com>
Co-authored-by: 非法操作 <hjlarry@163.com>
2026-01-04 20:24:49 +08:00
473f8ef29c feat: skip rerank if only one dataset is retrieved (#30075)
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-01-04 20:22:51 +08:00
wangxiaoleiandGitHub 96736144b9 feat: enhance squid config (#30146) 2026-01-04 19:59:41 +08:00
yyhandGitHub f167e87146 refactor(web): align signup mail submit and tests (#30456) 2026-01-04 19:59:06 +08:00
JoelGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>Copilotyyh
a562089e48 feat: add frontend code review skills (#30520)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-01-04 19:57:09 +08:00
yyhandGitHub 7d65d8048e feat: add Ralph Wiggum plugin support (#30525) 2026-01-04 19:56:02 +08:00
Coding On StarandGitHub c29cfd18f3 feat: revert model total credits (#30518) 2026-01-04 18:29:19 +08:00
47b8e979e0 test: add unit tests for RagPipeline components (#30429)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
2026-01-04 18:04:49 +08:00
-LAN-andGitHub 83648feedf chore: upgrade fickling to 0.1.6 (#30495) 2026-01-04 17:22:12 +08:00
Asuka MinatoandGitHub 2cef879209 refactor: more ns.model to BaseModel (#30445) 2026-01-04 17:12:28 +08:00
151101aaf5 chore(i18n): translate i18n files based on en-US changes (#30508)
Co-authored-by: hyoban <38493346+hyoban@users.noreply.github.com>
2026-01-04 17:11:40 +08:00
9aaa08e19f ci: fix translate, allow manual dispatch (#30505)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-04 16:34:23 +08:00
zhsamaandGitHub d4baf078f7 fix(plugins): enhance search to match name, label and description (#30501) 2026-01-04 16:07:04 +08:00
84cbf0526d feat: model total credits (#26942)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
2026-01-04 15:26:37 +08:00
82 changed files with 27718 additions and 1072 deletions
+2 -1
View File
@@ -3,6 +3,7 @@
"feature-dev@claude-plugins-official": true,
"context7@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": true,
"pyright-lsp@claude-plugins-official": true
"pyright-lsp@claude-plugins-official": true,
"ralph-wiggum@claude-plugins-official": true
}
}
@@ -0,0 +1,73 @@
---
name: frontend-code-review
description: "Trigger when the user requests a review of frontend files (e.g., `.tsx`, `.ts`, `.js`). Support both pending-change reviews and focused file reviews while applying the checklist rules."
---
# Frontend Code Review
## Intent
Use this skill whenever the user asks to review frontend code (especially `.tsx`, `.ts`, or `.js` files). Support two review modes:
1. **Pending-change review** inspect staged/working-tree files slated for commit and flag checklist violations before submission.
2. **File-targeted review** review the specific file(s) the user names and report the relevant checklist findings.
Stick to the checklist below for every applicable file and mode.
## Checklist
See [references/code-quality.md](references/code-quality.md), [references/performance.md](references/performance.md), [references/business-logic.md](references/business-logic.md) for the living checklist split by category—treat it as the canonical set of rules to follow.
Flag each rule violation with urgency metadata so future reviewers can prioritize fixes.
## Review Process
1. Open the relevant component/module. Gather lines that relate to class names, React Flow hooks, prop memoization, and styling.
2. For each rule in the review point, note where the code deviates and capture a representative snippet.
3. Compose the review section per the template below. Group violations first by **Urgent** flag, then by category order (Code Quality, Performance, Business Logic).
## Required output
When invoked, the response must exactly follow one of the two templates:
### Template A (any findings)
```
# Code review
Found <N> urgent issues need to be fixed:
## 1 <brief description of bug>
FilePath: <path> line <line>
<relevant code snippet or pointer>
### Suggested fix
<brief description of suggested fix>
---
... (repeat for each urgent issue) ...
Found <M> suggestions for improvement:
## 1 <brief description of suggestion>
FilePath: <path> line <line>
<relevant code snippet or pointer>
### Suggested fix
<brief description of suggested fix>
---
... (repeat for each suggestion) ...
```
If there are no urgent issues, omit that section. If there are no suggestions, omit that section.
If the issue number is more than 10, summarize as "10+ urgent issues" or "10+ suggestions" and just output the first 10 issues.
Don't compress the blank lines between sections; keep them as-is for readability.
If you use Template A (i.e., there are issues to fix) and at least one issue requires code changes, append a brief follow-up question after the structured output asking whether the user wants you to apply the suggested fix(es). For example: "Would you like me to use the Suggested fix section to address these issues?"
### Template B (no issues)
```
## Code review
No issues found.
```
@@ -0,0 +1,15 @@
# Rule Catalog — Business Logic
## Can't use workflowStore in Node components
IsUrgent: True
### Description
File path pattern of node components: `web/app/components/workflow/nodes/[nodeName]/node.tsx`
Node components are also used when creating a RAG Pipe from a template, but in that context there is no workflowStore Provider, which results in a blank screen. [This Issue](https://github.com/langgenius/dify/issues/29168) was caused by exactly this reason.
### Suggested Fix
Use `import { useNodes } from 'reactflow'` instead of `import useNodes from '@/app/components/workflow/store/workflow/use-nodes'`.
@@ -0,0 +1,44 @@
# Rule Catalog — Code Quality
## Conditional class names use utility function
IsUrgent: True
Category: Code Quality
### Description
Ensure conditional CSS is handled via the shared `classNames` instead of custom ternaries, string concatenation, or template strings. Centralizing class logic keeps components consistent and easier to maintain.
### Suggested Fix
```ts
import { cn } from '@/utils/classnames'
const classNames = cn(isActive ? 'text-primary-600' : 'text-gray-500')
```
## Tailwind-first styling
IsUrgent: True
Category: Code Quality
### Description
Favor Tailwind CSS utility classes instead of adding new `.module.css` files unless a Tailwind combination cannot achieve the required styling. Keeping styles in Tailwind improves consistency and reduces maintenance overhead.
Update this file when adding, editing, or removing Code Quality rules so the catalog remains accurate.
## Classname ordering for easy overrides
### Description
When writing components, always place the incoming `className` prop after the components own class values so that downstream consumers can override or extend the styling. This keeps your components defaults but still lets external callers change or remove specific styles.
Example:
```tsx
import { cn } from '@/utils/classnames'
const Button = ({ className }) => {
return <div className={cn('bg-primary-600', className)}></div>
}
```
@@ -0,0 +1,45 @@
# Rule Catalog — Performance
## React Flow data usage
IsUrgent: True
Category: Performance
### Description
When rendering React Flow, prefer `useNodes`/`useEdges` for UI consumption and rely on `useStoreApi` inside callbacks that mutate or read node/edge state. Avoid manually pulling Flow data outside of these hooks.
## Complex prop memoization
IsUrgent: True
Category: Performance
### Description
Wrap complex prop values (objects, arrays, maps) in `useMemo` prior to passing them into child components to guarantee stable references and prevent unnecessary renders.
Update this file when adding, editing, or removing Performance rules so the catalog remains accurate.
Wrong:
```tsx
<HeavyComp
config={{
provider: ...,
detail: ...
}}
/>
```
Right:
```tsx
const config = useMemo(() => ({
provider: ...,
detail: ...
}), [provider, detail]);
<HeavyComp
config={config}
/>
```
@@ -5,6 +5,7 @@ on:
branches: [main]
paths:
- 'web/i18n/en-US/*.json'
workflow_dispatch:
permissions:
contents: write
@@ -18,7 +19,8 @@ jobs:
run:
working-directory: web
steps:
- uses: actions/checkout@v6
# Keep use old checkout action version for https://github.com/peter-evans/create-pull-request/issues/4272
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@@ -26,21 +28,28 @@ jobs:
- name: Check for file changes in i18n/en-US
id: check_files
run: |
git fetch origin "${{ github.event.before }}" || true
git fetch origin "${{ github.sha }}" || true
changed_files=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'i18n/en-US/*.json')
echo "Changed files: $changed_files"
if [ -n "$changed_files" ]; then
# Skip check for manual trigger, translate all files
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "FILES_CHANGED=true" >> $GITHUB_ENV
file_args=""
for file in $changed_files; do
filename=$(basename "$file" .json)
file_args="$file_args --file $filename"
done
echo "FILE_ARGS=$file_args" >> $GITHUB_ENV
echo "File arguments: $file_args"
echo "FILE_ARGS=" >> $GITHUB_ENV
echo "Manual trigger: translating all files"
else
echo "FILES_CHANGED=false" >> $GITHUB_ENV
git fetch origin "${{ github.event.before }}" || true
git fetch origin "${{ github.sha }}" || true
changed_files=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'i18n/en-US/*.json')
echo "Changed files: $changed_files"
if [ -n "$changed_files" ]; then
echo "FILES_CHANGED=true" >> $GITHUB_ENV
file_args=""
for file in $changed_files; do
filename=$(basename "$file" .json)
file_args="$file_args --file $filename"
done
echo "FILE_ARGS=$file_args" >> $GITHUB_ENV
echo "File arguments: $file_args"
else
echo "FILES_CHANGED=false" >> $GITHUB_ENV
fi
fi
- name: Install pnpm
+1
View File
@@ -235,3 +235,4 @@ scripts/stress-test/reports/
# settings
*.local.json
*.local.md
+6 -1
View File
@@ -13,7 +13,6 @@ from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
from fields.conversation_fields import MessageTextField
from fields.raws import FilesContainedField
from libs.datetime_utils import naive_utc_now, parse_time_range
from libs.helper import TimestampField
@@ -177,6 +176,12 @@ annotation_hit_history_model = console_ns.model(
},
)
class MessageTextField(fields.Raw):
def format(self, value):
return value[0]["text"] if value else ""
# Simple message detail model
simple_message_detail_model = console_ns.model(
"SimpleMessageDetail",
+23 -10
View File
@@ -1,8 +1,7 @@
from typing import Any
from flask import request
from flask_restx import marshal_with
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Field, TypeAdapter, model_validator
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
@@ -11,7 +10,11 @@ from controllers.console.explore.error import NotChatAppError
from controllers.console.explore.wraps import InstalledAppResource
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
from fields.conversation_fields import conversation_infinite_scroll_pagination_fields, simple_conversation_fields
from fields.conversation_fields import (
ConversationInfiniteScrollPagination,
ResultResponse,
SimpleConversation,
)
from libs.helper import UUIDStrOrEmpty
from libs.login import current_user
from models import Account
@@ -49,7 +52,6 @@ register_schema_models(console_ns, ConversationListQuery, ConversationRenamePayl
endpoint="installed_app_conversations",
)
class ConversationListApi(InstalledAppResource):
@marshal_with(conversation_infinite_scroll_pagination_fields)
@console_ns.expect(console_ns.models[ConversationListQuery.__name__])
def get(self, installed_app):
app_model = installed_app.app
@@ -73,7 +75,7 @@ class ConversationListApi(InstalledAppResource):
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
with Session(db.engine) as session:
return WebConversationService.pagination_by_last_id(
pagination = WebConversationService.pagination_by_last_id(
session=session,
app_model=app_model,
user=current_user,
@@ -82,6 +84,13 @@ class ConversationListApi(InstalledAppResource):
invoke_from=InvokeFrom.EXPLORE,
pinned=args.pinned,
)
adapter = TypeAdapter(SimpleConversation)
conversations = [adapter.validate_python(item, from_attributes=True) for item in pagination.data]
return ConversationInfiniteScrollPagination(
limit=pagination.limit,
has_more=pagination.has_more,
data=conversations,
).model_dump(mode="json")
except LastConversationNotExistsError:
raise NotFound("Last Conversation Not Exists.")
@@ -105,7 +114,7 @@ class ConversationApi(InstalledAppResource):
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
return {"result": "success"}, 204
return ResultResponse(result="success").model_dump(mode="json"), 204
@console_ns.route(
@@ -113,7 +122,6 @@ class ConversationApi(InstalledAppResource):
endpoint="installed_app_conversation_rename",
)
class ConversationRenameApi(InstalledAppResource):
@marshal_with(simple_conversation_fields)
@console_ns.expect(console_ns.models[ConversationRenamePayload.__name__])
def post(self, installed_app, c_id):
app_model = installed_app.app
@@ -128,9 +136,14 @@ class ConversationRenameApi(InstalledAppResource):
try:
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
return ConversationService.rename(
conversation = ConversationService.rename(
app_model, conversation_id, current_user, payload.name, payload.auto_generate
)
return (
TypeAdapter(SimpleConversation)
.validate_python(conversation, from_attributes=True)
.model_dump(mode="json")
)
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@@ -155,7 +168,7 @@ class ConversationPinApi(InstalledAppResource):
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
return {"result": "success"}
return ResultResponse(result="success").model_dump(mode="json")
@console_ns.route(
@@ -174,4 +187,4 @@ class ConversationUnPinApi(InstalledAppResource):
raise ValueError("current_user must be an Account instance")
WebConversationService.unpin(app_model, conversation_id, current_user)
return {"result": "success"}
return ResultResponse(result="success").model_dump(mode="json")
+13 -7
View File
@@ -2,8 +2,7 @@ import logging
from typing import Literal
from flask import request
from flask_restx import marshal_with
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, TypeAdapter
from werkzeug.exceptions import InternalServerError, NotFound
from controllers.common.schema import register_schema_models
@@ -23,7 +22,8 @@ from controllers.console.explore.wraps import InstalledAppResource
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from core.model_runtime.errors.invoke import InvokeError
from fields.message_fields import message_infinite_scroll_pagination_fields
from fields.conversation_fields import ResultResponse
from fields.message_fields import MessageInfiniteScrollPagination, MessageListItem, SuggestedQuestionsResponse
from libs import helper
from libs.helper import UUIDStrOrEmpty
from libs.login import current_account_with_tenant
@@ -66,7 +66,6 @@ register_schema_models(console_ns, MessageListQuery, MessageFeedbackPayload, Mor
endpoint="installed_app_messages",
)
class MessageListApi(InstalledAppResource):
@marshal_with(message_infinite_scroll_pagination_fields)
@console_ns.expect(console_ns.models[MessageListQuery.__name__])
def get(self, installed_app):
current_user, _ = current_account_with_tenant()
@@ -78,13 +77,20 @@ class MessageListApi(InstalledAppResource):
args = MessageListQuery.model_validate(request.args.to_dict())
try:
return MessageService.pagination_by_first_id(
pagination = MessageService.pagination_by_first_id(
app_model,
current_user,
str(args.conversation_id),
str(args.first_id) if args.first_id else None,
args.limit,
)
adapter = TypeAdapter(MessageListItem)
items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data]
return MessageInfiniteScrollPagination(
limit=pagination.limit,
has_more=pagination.has_more,
data=items,
).model_dump(mode="json")
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except FirstMessageNotExistsError:
@@ -116,7 +122,7 @@ class MessageFeedbackApi(InstalledAppResource):
except MessageNotExistsError:
raise NotFound("Message Not Exists.")
return {"result": "success"}
return ResultResponse(result="success").model_dump(mode="json")
@console_ns.route(
@@ -201,4 +207,4 @@ class MessageSuggestedQuestionApi(InstalledAppResource):
logger.exception("internal server error.")
raise InternalServerError()
return {"data": questions}
return SuggestedQuestionsResponse(data=questions).model_dump(mode="json")
@@ -1,14 +1,14 @@
from flask import request
from flask_restx import fields, marshal_with
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, TypeAdapter
from werkzeug.exceptions import NotFound
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.explore.error import NotCompletionAppError
from controllers.console.explore.wraps import InstalledAppResource
from fields.conversation_fields import message_file_fields
from libs.helper import TimestampField, UUIDStrOrEmpty
from fields.conversation_fields import ResultResponse
from fields.message_fields import SavedMessageInfiniteScrollPagination, SavedMessageItem
from libs.helper import UUIDStrOrEmpty
from libs.login import current_account_with_tenant
from services.errors.message import MessageNotExistsError
from services.saved_message_service import SavedMessageService
@@ -26,28 +26,8 @@ class SavedMessageCreatePayload(BaseModel):
register_schema_models(console_ns, SavedMessageListQuery, SavedMessageCreatePayload)
feedback_fields = {"rating": fields.String}
message_fields = {
"id": fields.String,
"inputs": fields.Raw,
"query": fields.String,
"answer": fields.String,
"message_files": fields.List(fields.Nested(message_file_fields)),
"feedback": fields.Nested(feedback_fields, attribute="user_feedback", allow_null=True),
"created_at": TimestampField,
}
@console_ns.route("/installed-apps/<uuid:installed_app_id>/saved-messages", endpoint="installed_app_saved_messages")
class SavedMessageListApi(InstalledAppResource):
saved_message_infinite_scroll_pagination_fields = {
"limit": fields.Integer,
"has_more": fields.Boolean,
"data": fields.List(fields.Nested(message_fields)),
}
@marshal_with(saved_message_infinite_scroll_pagination_fields)
@console_ns.expect(console_ns.models[SavedMessageListQuery.__name__])
def get(self, installed_app):
current_user, _ = current_account_with_tenant()
@@ -57,12 +37,19 @@ class SavedMessageListApi(InstalledAppResource):
args = SavedMessageListQuery.model_validate(request.args.to_dict())
return SavedMessageService.pagination_by_last_id(
pagination = SavedMessageService.pagination_by_last_id(
app_model,
current_user,
str(args.last_id) if args.last_id else None,
args.limit,
)
adapter = TypeAdapter(SavedMessageItem)
items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data]
return SavedMessageInfiniteScrollPagination(
limit=pagination.limit,
has_more=pagination.has_more,
data=items,
).model_dump(mode="json")
@console_ns.expect(console_ns.models[SavedMessageCreatePayload.__name__])
def post(self, installed_app):
@@ -78,7 +65,7 @@ class SavedMessageListApi(InstalledAppResource):
except MessageNotExistsError:
raise NotFound("Message Not Exists.")
return {"result": "success"}
return ResultResponse(result="success").model_dump(mode="json")
@console_ns.route(
@@ -96,4 +83,4 @@ class SavedMessageApi(InstalledAppResource):
SavedMessageService.delete(app_model, current_user, message_id)
return {"result": "success"}, 204
return ResultResponse(result="success").model_dump(mode="json"), 204
+21 -11
View File
@@ -3,8 +3,7 @@ from uuid import UUID
from flask import request
from flask_restx import Resource
from flask_restx._http import HTTPStatus
from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic import BaseModel, Field, TypeAdapter, field_validator, model_validator
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, NotFound
@@ -16,9 +15,9 @@ from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
from fields.conversation_fields import (
build_conversation_delete_model,
build_conversation_infinite_scroll_pagination_model,
build_simple_conversation_model,
ConversationDelete,
ConversationInfiniteScrollPagination,
SimpleConversation,
)
from fields.conversation_variable_fields import (
build_conversation_variable_infinite_scroll_pagination_model,
@@ -105,7 +104,6 @@ class ConversationApi(Resource):
}
)
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
@service_api_ns.marshal_with(build_conversation_infinite_scroll_pagination_model(service_api_ns))
def get(self, app_model: App, end_user: EndUser):
"""List all conversations for the current user.
@@ -120,7 +118,7 @@ class ConversationApi(Resource):
try:
with Session(db.engine) as session:
return ConversationService.pagination_by_last_id(
pagination = ConversationService.pagination_by_last_id(
session=session,
app_model=app_model,
user=end_user,
@@ -129,6 +127,13 @@ class ConversationApi(Resource):
invoke_from=InvokeFrom.SERVICE_API,
sort_by=query_args.sort_by,
)
adapter = TypeAdapter(SimpleConversation)
conversations = [adapter.validate_python(item, from_attributes=True) for item in pagination.data]
return ConversationInfiniteScrollPagination(
limit=pagination.limit,
has_more=pagination.has_more,
data=conversations,
).model_dump(mode="json")
except services.errors.conversation.LastConversationNotExistsError:
raise NotFound("Last Conversation Not Exists.")
@@ -146,7 +151,6 @@ class ConversationDetailApi(Resource):
}
)
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON))
@service_api_ns.marshal_with(build_conversation_delete_model(service_api_ns), code=HTTPStatus.NO_CONTENT)
def delete(self, app_model: App, end_user: EndUser, c_id):
"""Delete a specific conversation."""
app_mode = AppMode.value_of(app_model.mode)
@@ -159,7 +163,7 @@ class ConversationDetailApi(Resource):
ConversationService.delete(app_model, conversation_id, end_user)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
return {"result": "success"}, 204
return ConversationDelete(result="success").model_dump(mode="json"), 204
@service_api_ns.route("/conversations/<uuid:c_id>/name")
@@ -176,7 +180,6 @@ class ConversationRenameApi(Resource):
}
)
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON))
@service_api_ns.marshal_with(build_simple_conversation_model(service_api_ns))
def post(self, app_model: App, end_user: EndUser, c_id):
"""Rename a conversation or auto-generate a name."""
app_mode = AppMode.value_of(app_model.mode)
@@ -188,7 +191,14 @@ class ConversationRenameApi(Resource):
payload = ConversationRenamePayload.model_validate(service_api_ns.payload or {})
try:
return ConversationService.rename(app_model, conversation_id, end_user, payload.name, payload.auto_generate)
conversation = ConversationService.rename(
app_model, conversation_id, end_user, payload.name, payload.auto_generate
)
return (
TypeAdapter(SimpleConversation)
.validate_python(conversation, from_attributes=True)
.model_dump(mode="json")
)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
+13 -53
View File
@@ -1,11 +1,10 @@
import json
import logging
from typing import Literal
from uuid import UUID
from flask import request
from flask_restx import Namespace, Resource, fields
from pydantic import BaseModel, Field
from flask_restx import Resource
from pydantic import BaseModel, Field, TypeAdapter
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
import services
@@ -14,10 +13,8 @@ from controllers.service_api import service_api_ns
from controllers.service_api.app.error import NotChatAppError
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from core.app.entities.app_invoke_entities import InvokeFrom
from fields.conversation_fields import build_message_file_model
from fields.message_fields import build_agent_thought_model, build_feedback_model
from fields.raws import FilesContainedField
from libs.helper import TimestampField
from fields.conversation_fields import ResultResponse
from fields.message_fields import MessageInfiniteScrollPagination, MessageListItem
from models.model import App, AppMode, EndUser
from services.errors.message import (
FirstMessageNotExistsError,
@@ -48,49 +45,6 @@ class FeedbackListQuery(BaseModel):
register_schema_models(service_api_ns, MessageListQuery, MessageFeedbackPayload, FeedbackListQuery)
def build_message_model(api_or_ns: Namespace):
"""Build the message model for the API or Namespace."""
# First build the nested models
feedback_model = build_feedback_model(api_or_ns)
agent_thought_model = build_agent_thought_model(api_or_ns)
message_file_model = build_message_file_model(api_or_ns)
# Then build the message fields with nested models
message_fields = {
"id": fields.String,
"conversation_id": fields.String,
"parent_message_id": fields.String,
"inputs": FilesContainedField,
"query": fields.String,
"answer": fields.String(attribute="re_sign_file_url_answer"),
"message_files": fields.List(fields.Nested(message_file_model)),
"feedback": fields.Nested(feedback_model, attribute="user_feedback", allow_null=True),
"retriever_resources": fields.Raw(
attribute=lambda obj: json.loads(obj.message_metadata).get("retriever_resources", [])
if obj.message_metadata
else []
),
"created_at": TimestampField,
"agent_thoughts": fields.List(fields.Nested(agent_thought_model)),
"status": fields.String,
"error": fields.String,
}
return api_or_ns.model("Message", message_fields)
def build_message_infinite_scroll_pagination_model(api_or_ns: Namespace):
"""Build the message infinite scroll pagination model for the API or Namespace."""
# Build the nested message model first
message_model = build_message_model(api_or_ns)
message_infinite_scroll_pagination_fields = {
"limit": fields.Integer,
"has_more": fields.Boolean,
"data": fields.List(fields.Nested(message_model)),
}
return api_or_ns.model("MessageInfiniteScrollPagination", message_infinite_scroll_pagination_fields)
@service_api_ns.route("/messages")
class MessageListApi(Resource):
@service_api_ns.expect(service_api_ns.models[MessageListQuery.__name__])
@@ -104,7 +58,6 @@ class MessageListApi(Resource):
}
)
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
@service_api_ns.marshal_with(build_message_infinite_scroll_pagination_model(service_api_ns))
def get(self, app_model: App, end_user: EndUser):
"""List messages in a conversation.
@@ -119,9 +72,16 @@ class MessageListApi(Resource):
first_id = str(query_args.first_id) if query_args.first_id else None
try:
return MessageService.pagination_by_first_id(
pagination = MessageService.pagination_by_first_id(
app_model, end_user, conversation_id, first_id, query_args.limit
)
adapter = TypeAdapter(MessageListItem)
items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data]
return MessageInfiniteScrollPagination(
limit=pagination.limit,
has_more=pagination.has_more,
data=items,
).model_dump(mode="json")
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except FirstMessageNotExistsError:
@@ -162,7 +122,7 @@ class MessageFeedbackApi(Resource):
except MessageNotExistsError:
raise NotFound("Message Not Exists.")
return {"result": "success"}
return ResultResponse(result="success").model_dump(mode="json")
@service_api_ns.route("/app/feedbacks")
+26 -24
View File
@@ -1,5 +1,6 @@
from flask_restx import fields, marshal_with, reqparse
from flask_restx import reqparse
from flask_restx.inputs import int_range
from pydantic import TypeAdapter
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
@@ -8,7 +9,11 @@ from controllers.web.error import NotChatAppError
from controllers.web.wraps import WebApiResource
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
from fields.conversation_fields import conversation_infinite_scroll_pagination_fields, simple_conversation_fields
from fields.conversation_fields import (
ConversationInfiniteScrollPagination,
ResultResponse,
SimpleConversation,
)
from libs.helper import uuid_value
from models.model import AppMode
from services.conversation_service import ConversationService
@@ -54,7 +59,6 @@ class ConversationListApi(WebApiResource):
500: "Internal Server Error",
}
)
@marshal_with(conversation_infinite_scroll_pagination_fields)
def get(self, app_model, end_user):
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
@@ -82,7 +86,7 @@ class ConversationListApi(WebApiResource):
try:
with Session(db.engine) as session:
return WebConversationService.pagination_by_last_id(
pagination = WebConversationService.pagination_by_last_id(
session=session,
app_model=app_model,
user=end_user,
@@ -92,16 +96,19 @@ class ConversationListApi(WebApiResource):
pinned=pinned,
sort_by=args["sort_by"],
)
adapter = TypeAdapter(SimpleConversation)
conversations = [adapter.validate_python(item, from_attributes=True) for item in pagination.data]
return ConversationInfiniteScrollPagination(
limit=pagination.limit,
has_more=pagination.has_more,
data=conversations,
).model_dump(mode="json")
except LastConversationNotExistsError:
raise NotFound("Last Conversation Not Exists.")
@web_ns.route("/conversations/<uuid:c_id>")
class ConversationApi(WebApiResource):
delete_response_fields = {
"result": fields.String,
}
@web_ns.doc("Delete Conversation")
@web_ns.doc(description="Delete a specific conversation.")
@web_ns.doc(params={"c_id": {"description": "Conversation UUID", "type": "string", "required": True}})
@@ -115,7 +122,6 @@ class ConversationApi(WebApiResource):
500: "Internal Server Error",
}
)
@marshal_with(delete_response_fields)
def delete(self, app_model, end_user, c_id):
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
@@ -126,7 +132,7 @@ class ConversationApi(WebApiResource):
ConversationService.delete(app_model, conversation_id, end_user)
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
return {"result": "success"}, 204
return ResultResponse(result="success").model_dump(mode="json"), 204
@web_ns.route("/conversations/<uuid:c_id>/name")
@@ -155,7 +161,6 @@ class ConversationRenameApi(WebApiResource):
500: "Internal Server Error",
}
)
@marshal_with(simple_conversation_fields)
def post(self, app_model, end_user, c_id):
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
@@ -171,17 +176,20 @@ class ConversationRenameApi(WebApiResource):
args = parser.parse_args()
try:
return ConversationService.rename(app_model, conversation_id, end_user, args["name"], args["auto_generate"])
conversation = ConversationService.rename(
app_model, conversation_id, end_user, args["name"], args["auto_generate"]
)
return (
TypeAdapter(SimpleConversation)
.validate_python(conversation, from_attributes=True)
.model_dump(mode="json")
)
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@web_ns.route("/conversations/<uuid:c_id>/pin")
class ConversationPinApi(WebApiResource):
pin_response_fields = {
"result": fields.String,
}
@web_ns.doc("Pin Conversation")
@web_ns.doc(description="Pin a specific conversation to keep it at the top of the list.")
@web_ns.doc(params={"c_id": {"description": "Conversation UUID", "type": "string", "required": True}})
@@ -195,7 +203,6 @@ class ConversationPinApi(WebApiResource):
500: "Internal Server Error",
}
)
@marshal_with(pin_response_fields)
def patch(self, app_model, end_user, c_id):
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
@@ -208,15 +215,11 @@ class ConversationPinApi(WebApiResource):
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
return {"result": "success"}
return ResultResponse(result="success").model_dump(mode="json")
@web_ns.route("/conversations/<uuid:c_id>/unpin")
class ConversationUnPinApi(WebApiResource):
unpin_response_fields = {
"result": fields.String,
}
@web_ns.doc("Unpin Conversation")
@web_ns.doc(description="Unpin a specific conversation to remove it from the top of the list.")
@web_ns.doc(params={"c_id": {"description": "Conversation UUID", "type": "string", "required": True}})
@@ -230,7 +233,6 @@ class ConversationUnPinApi(WebApiResource):
500: "Internal Server Error",
}
)
@marshal_with(unpin_response_fields)
def patch(self, app_model, end_user, c_id):
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
@@ -239,4 +241,4 @@ class ConversationUnPinApi(WebApiResource):
conversation_id = str(c_id)
WebConversationService.unpin(app_model, conversation_id, end_user)
return {"result": "success"}
return ResultResponse(result="success").model_dump(mode="json")
+14 -44
View File
@@ -2,8 +2,7 @@ import logging
from typing import Literal
from flask import request
from flask_restx import fields, marshal_with
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, TypeAdapter, field_validator
from werkzeug.exceptions import InternalServerError, NotFound
from controllers.common.schema import register_schema_models
@@ -22,11 +21,10 @@ from controllers.web.wraps import WebApiResource
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from core.model_runtime.errors.invoke import InvokeError
from fields.conversation_fields import message_file_fields
from fields.message_fields import agent_thought_fields, feedback_fields, retriever_resource_fields
from fields.raws import FilesContainedField
from fields.conversation_fields import ResultResponse
from fields.message_fields import SuggestedQuestionsResponse, WebMessageInfiniteScrollPagination, WebMessageListItem
from libs import helper
from libs.helper import TimestampField, uuid_value
from libs.helper import uuid_value
from models.model import AppMode
from services.app_generate_service import AppGenerateService
from services.errors.app import MoreLikeThisDisabledError
@@ -70,29 +68,6 @@ register_schema_models(web_ns, MessageListQuery, MessageFeedbackPayload, Message
@web_ns.route("/messages")
class MessageListApi(WebApiResource):
message_fields = {
"id": fields.String,
"conversation_id": fields.String,
"parent_message_id": fields.String,
"inputs": FilesContainedField,
"query": fields.String,
"answer": fields.String(attribute="re_sign_file_url_answer"),
"message_files": fields.List(fields.Nested(message_file_fields)),
"feedback": fields.Nested(feedback_fields, attribute="user_feedback", allow_null=True),
"retriever_resources": fields.List(fields.Nested(retriever_resource_fields)),
"created_at": TimestampField,
"agent_thoughts": fields.List(fields.Nested(agent_thought_fields)),
"metadata": fields.Raw(attribute="message_metadata_dict"),
"status": fields.String,
"error": fields.String,
}
message_infinite_scroll_pagination_fields = {
"limit": fields.Integer,
"has_more": fields.Boolean,
"data": fields.List(fields.Nested(message_fields)),
}
@web_ns.doc("Get Message List")
@web_ns.doc(description="Retrieve paginated list of messages from a conversation in a chat application.")
@web_ns.doc(
@@ -121,7 +96,6 @@ class MessageListApi(WebApiResource):
500: "Internal Server Error",
}
)
@marshal_with(message_infinite_scroll_pagination_fields)
def get(self, app_model, end_user):
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
@@ -131,9 +105,16 @@ class MessageListApi(WebApiResource):
query = MessageListQuery.model_validate(raw_args)
try:
return MessageService.pagination_by_first_id(
pagination = MessageService.pagination_by_first_id(
app_model, end_user, query.conversation_id, query.first_id, query.limit
)
adapter = TypeAdapter(WebMessageListItem)
items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data]
return WebMessageInfiniteScrollPagination(
limit=pagination.limit,
has_more=pagination.has_more,
data=items,
).model_dump(mode="json")
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except FirstMessageNotExistsError:
@@ -142,10 +123,6 @@ class MessageListApi(WebApiResource):
@web_ns.route("/messages/<uuid:message_id>/feedbacks")
class MessageFeedbackApi(WebApiResource):
feedback_response_fields = {
"result": fields.String,
}
@web_ns.doc("Create Message Feedback")
@web_ns.doc(description="Submit feedback (like/dislike) for a specific message.")
@web_ns.doc(params={"message_id": {"description": "Message UUID", "type": "string", "required": True}})
@@ -170,7 +147,6 @@ class MessageFeedbackApi(WebApiResource):
500: "Internal Server Error",
}
)
@marshal_with(feedback_response_fields)
def post(self, app_model, end_user, message_id):
message_id = str(message_id)
@@ -187,7 +163,7 @@ class MessageFeedbackApi(WebApiResource):
except MessageNotExistsError:
raise NotFound("Message Not Exists.")
return {"result": "success"}
return ResultResponse(result="success").model_dump(mode="json")
@web_ns.route("/messages/<uuid:message_id>/more-like-this")
@@ -247,10 +223,6 @@ class MessageMoreLikeThisApi(WebApiResource):
@web_ns.route("/messages/<uuid:message_id>/suggested-questions")
class MessageSuggestedQuestionApi(WebApiResource):
suggested_questions_response_fields = {
"data": fields.List(fields.String),
}
@web_ns.doc("Get Suggested Questions")
@web_ns.doc(description="Get suggested follow-up questions after a message (chat apps only).")
@web_ns.doc(params={"message_id": {"description": "Message UUID", "type": "string", "required": True}})
@@ -264,7 +236,6 @@ class MessageSuggestedQuestionApi(WebApiResource):
500: "Internal Server Error",
}
)
@marshal_with(suggested_questions_response_fields)
def get(self, app_model, end_user, message_id):
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
@@ -277,7 +248,6 @@ class MessageSuggestedQuestionApi(WebApiResource):
app_model=app_model, user=end_user, message_id=message_id, invoke_from=InvokeFrom.WEB_APP
)
# questions is a list of strings, not a list of Message objects
# so we can directly return it
except MessageNotExistsError:
raise NotFound("Message not found")
except ConversationNotExistsError:
@@ -296,4 +266,4 @@ class MessageSuggestedQuestionApi(WebApiResource):
logger.exception("internal server error.")
raise InternalServerError()
return {"data": questions}
return SuggestedQuestionsResponse(data=questions).model_dump(mode="json")
+15 -35
View File
@@ -1,40 +1,20 @@
from flask_restx import fields, marshal_with, reqparse
from flask_restx import reqparse
from flask_restx.inputs import int_range
from pydantic import TypeAdapter
from werkzeug.exceptions import NotFound
from controllers.web import web_ns
from controllers.web.error import NotCompletionAppError
from controllers.web.wraps import WebApiResource
from fields.conversation_fields import message_file_fields
from libs.helper import TimestampField, uuid_value
from fields.conversation_fields import ResultResponse
from fields.message_fields import SavedMessageInfiniteScrollPagination, SavedMessageItem
from libs.helper import uuid_value
from services.errors.message import MessageNotExistsError
from services.saved_message_service import SavedMessageService
feedback_fields = {"rating": fields.String}
message_fields = {
"id": fields.String,
"inputs": fields.Raw,
"query": fields.String,
"answer": fields.String,
"message_files": fields.List(fields.Nested(message_file_fields)),
"feedback": fields.Nested(feedback_fields, attribute="user_feedback", allow_null=True),
"created_at": TimestampField,
}
@web_ns.route("/saved-messages")
class SavedMessageListApi(WebApiResource):
saved_message_infinite_scroll_pagination_fields = {
"limit": fields.Integer,
"has_more": fields.Boolean,
"data": fields.List(fields.Nested(message_fields)),
}
post_response_fields = {
"result": fields.String,
}
@web_ns.doc("Get Saved Messages")
@web_ns.doc(description="Retrieve paginated list of saved messages for a completion application.")
@web_ns.doc(
@@ -58,7 +38,6 @@ class SavedMessageListApi(WebApiResource):
500: "Internal Server Error",
}
)
@marshal_with(saved_message_infinite_scroll_pagination_fields)
def get(self, app_model, end_user):
if app_model.mode != "completion":
raise NotCompletionAppError()
@@ -70,7 +49,14 @@ class SavedMessageListApi(WebApiResource):
)
args = parser.parse_args()
return SavedMessageService.pagination_by_last_id(app_model, end_user, args["last_id"], args["limit"])
pagination = SavedMessageService.pagination_by_last_id(app_model, end_user, args["last_id"], args["limit"])
adapter = TypeAdapter(SavedMessageItem)
items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data]
return SavedMessageInfiniteScrollPagination(
limit=pagination.limit,
has_more=pagination.has_more,
data=items,
).model_dump(mode="json")
@web_ns.doc("Save Message")
@web_ns.doc(description="Save a specific message for later reference.")
@@ -89,7 +75,6 @@ class SavedMessageListApi(WebApiResource):
500: "Internal Server Error",
}
)
@marshal_with(post_response_fields)
def post(self, app_model, end_user):
if app_model.mode != "completion":
raise NotCompletionAppError()
@@ -102,15 +87,11 @@ class SavedMessageListApi(WebApiResource):
except MessageNotExistsError:
raise NotFound("Message Not Exists.")
return {"result": "success"}
return ResultResponse(result="success").model_dump(mode="json")
@web_ns.route("/saved-messages/<uuid:message_id>")
class SavedMessageApi(WebApiResource):
delete_response_fields = {
"result": fields.String,
}
@web_ns.doc("Delete Saved Message")
@web_ns.doc(description="Remove a message from saved messages.")
@web_ns.doc(params={"message_id": {"description": "Message UUID to delete", "type": "string", "required": True}})
@@ -124,7 +105,6 @@ class SavedMessageApi(WebApiResource):
500: "Internal Server Error",
}
)
@marshal_with(delete_response_fields)
def delete(self, app_model, end_user, message_id):
message_id = str(message_id)
@@ -133,4 +113,4 @@ class SavedMessageApi(WebApiResource):
SavedMessageService.delete(app_model, end_user, message_id)
return {"result": "success"}, 204
return ResultResponse(result="success").model_dump(mode="json"), 204
+6 -1
View File
@@ -515,6 +515,7 @@ class DatasetRetrieval:
0
].embedding_model_provider
weights["vector_setting"]["embedding_model_name"] = available_datasets[0].embedding_model
dataset_count = len(available_datasets)
with measure_time() as timer:
cancel_event = threading.Event()
thread_exceptions: list[Exception] = []
@@ -537,6 +538,7 @@ class DatasetRetrieval:
"score_threshold": score_threshold,
"query": query,
"attachment_id": None,
"dataset_count": dataset_count,
"cancel_event": cancel_event,
"thread_exceptions": thread_exceptions,
},
@@ -562,6 +564,7 @@ class DatasetRetrieval:
"score_threshold": score_threshold,
"query": None,
"attachment_id": attachment_id,
"dataset_count": dataset_count,
"cancel_event": cancel_event,
"thread_exceptions": thread_exceptions,
},
@@ -1422,6 +1425,7 @@ class DatasetRetrieval:
score_threshold: float,
query: str | None,
attachment_id: str | None,
dataset_count: int,
cancel_event: threading.Event | None = None,
thread_exceptions: list[Exception] | None = None,
):
@@ -1470,7 +1474,8 @@ class DatasetRetrieval:
if cancel_event and cancel_event.is_set():
break
if reranking_enable:
# Skip second reranking when there is only one dataset
if reranking_enable and dataset_count > 1:
# do rerank for searched documents
data_post_processor = DataPostProcessor(tenant_id, reranking_mode, reranking_model, weights, False)
if query:
+63 -19
View File
@@ -1,8 +1,7 @@
from collections.abc import Mapping, Sequence
from decimal import Decimal
from typing import Any, cast
from typing import TYPE_CHECKING, Any, ClassVar, cast
from configs import dify_config
from core.helper.code_executor.code_executor import CodeExecutionError, CodeExecutor, CodeLanguage
from core.helper.code_executor.code_node_provider import CodeNodeProvider
from core.helper.code_executor.javascript.javascript_code_provider import JavascriptCodeProvider
@@ -13,6 +12,7 @@ from core.workflow.enums import NodeType, WorkflowNodeExecutionStatus
from core.workflow.node_events import NodeRunResult
from core.workflow.nodes.base.node import Node
from core.workflow.nodes.code.entities import CodeNodeData
from core.workflow.nodes.code.limits import CodeNodeLimits
from .exc import (
CodeNodeError,
@@ -20,9 +20,41 @@ from .exc import (
OutputValidationError,
)
if TYPE_CHECKING:
from core.workflow.entities import GraphInitParams
from core.workflow.runtime import GraphRuntimeState
class CodeNode(Node[CodeNodeData]):
node_type = NodeType.CODE
_DEFAULT_CODE_PROVIDERS: ClassVar[tuple[type[CodeNodeProvider], ...]] = (
Python3CodeProvider,
JavascriptCodeProvider,
)
_limits: CodeNodeLimits
def __init__(
self,
id: str,
config: Mapping[str, Any],
graph_init_params: "GraphInitParams",
graph_runtime_state: "GraphRuntimeState",
*,
code_executor: type[CodeExecutor] | None = None,
code_providers: Sequence[type[CodeNodeProvider]] | None = None,
code_limits: CodeNodeLimits,
) -> None:
super().__init__(
id=id,
config=config,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
)
self._code_executor: type[CodeExecutor] = code_executor or CodeExecutor
self._code_providers: tuple[type[CodeNodeProvider], ...] = (
tuple(code_providers) if code_providers else self._DEFAULT_CODE_PROVIDERS
)
self._limits = code_limits
@classmethod
def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]:
@@ -35,11 +67,16 @@ class CodeNode(Node[CodeNodeData]):
if filters:
code_language = cast(CodeLanguage, filters.get("code_language", CodeLanguage.PYTHON3))
providers: list[type[CodeNodeProvider]] = [Python3CodeProvider, JavascriptCodeProvider]
code_provider: type[CodeNodeProvider] = next(p for p in providers if p.is_accept_language(code_language))
code_provider: type[CodeNodeProvider] = next(
provider for provider in cls._DEFAULT_CODE_PROVIDERS if provider.is_accept_language(code_language)
)
return code_provider.get_default_config()
@classmethod
def default_code_providers(cls) -> tuple[type[CodeNodeProvider], ...]:
return cls._DEFAULT_CODE_PROVIDERS
@classmethod
def version(cls) -> str:
return "1"
@@ -60,7 +97,8 @@ class CodeNode(Node[CodeNodeData]):
variables[variable_name] = variable.to_object() if variable else None
# Run code
try:
result = CodeExecutor.execute_workflow_code_template(
_ = self._select_code_provider(code_language)
result = self._code_executor.execute_workflow_code_template(
language=code_language,
code=code,
inputs=variables,
@@ -75,6 +113,12 @@ class CodeNode(Node[CodeNodeData]):
return NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=variables, outputs=result)
def _select_code_provider(self, code_language: CodeLanguage) -> type[CodeNodeProvider]:
for provider in self._code_providers:
if provider.is_accept_language(code_language):
return provider
raise CodeNodeError(f"Unsupported code language: {code_language}")
def _check_string(self, value: str | None, variable: str) -> str | None:
"""
Check string
@@ -85,10 +129,10 @@ class CodeNode(Node[CodeNodeData]):
if value is None:
return None
if len(value) > dify_config.CODE_MAX_STRING_LENGTH:
if len(value) > self._limits.max_string_length:
raise OutputValidationError(
f"The length of output variable `{variable}` must be"
f" less than {dify_config.CODE_MAX_STRING_LENGTH} characters"
f" less than {self._limits.max_string_length} characters"
)
return value.replace("\x00", "")
@@ -109,20 +153,20 @@ class CodeNode(Node[CodeNodeData]):
if value is None:
return None
if value > dify_config.CODE_MAX_NUMBER or value < dify_config.CODE_MIN_NUMBER:
if value > self._limits.max_number or value < self._limits.min_number:
raise OutputValidationError(
f"Output variable `{variable}` is out of range,"
f" it must be between {dify_config.CODE_MIN_NUMBER} and {dify_config.CODE_MAX_NUMBER}."
f" it must be between {self._limits.min_number} and {self._limits.max_number}."
)
if isinstance(value, float):
decimal_value = Decimal(str(value)).normalize()
precision = -decimal_value.as_tuple().exponent if decimal_value.as_tuple().exponent < 0 else 0 # type: ignore[operator]
# raise error if precision is too high
if precision > dify_config.CODE_MAX_PRECISION:
if precision > self._limits.max_precision:
raise OutputValidationError(
f"Output variable `{variable}` has too high precision,"
f" it must be less than {dify_config.CODE_MAX_PRECISION} digits."
f" it must be less than {self._limits.max_precision} digits."
)
return value
@@ -137,8 +181,8 @@ class CodeNode(Node[CodeNodeData]):
# TODO(QuantumGhost): Replace native Python lists with `Array*Segment` classes.
# Note that `_transform_result` may produce lists containing `None` values,
# which don't conform to the type requirements of `Array*Segment` classes.
if depth > dify_config.CODE_MAX_DEPTH:
raise DepthLimitError(f"Depth limit {dify_config.CODE_MAX_DEPTH} reached, object too deep.")
if depth > self._limits.max_depth:
raise DepthLimitError(f"Depth limit {self._limits.max_depth} reached, object too deep.")
transformed_result: dict[str, Any] = {}
if output_schema is None:
@@ -272,10 +316,10 @@ class CodeNode(Node[CodeNodeData]):
f"Output {prefix}{dot}{output_name} is not an array, got {type(value)} instead."
)
else:
if len(value) > dify_config.CODE_MAX_NUMBER_ARRAY_LENGTH:
if len(value) > self._limits.max_number_array_length:
raise OutputValidationError(
f"The length of output variable `{prefix}{dot}{output_name}` must be"
f" less than {dify_config.CODE_MAX_NUMBER_ARRAY_LENGTH} elements."
f" less than {self._limits.max_number_array_length} elements."
)
for i, inner_value in enumerate(value):
@@ -305,10 +349,10 @@ class CodeNode(Node[CodeNodeData]):
f" got {type(result.get(output_name))} instead."
)
else:
if len(result[output_name]) > dify_config.CODE_MAX_STRING_ARRAY_LENGTH:
if len(result[output_name]) > self._limits.max_string_array_length:
raise OutputValidationError(
f"The length of output variable `{prefix}{dot}{output_name}` must be"
f" less than {dify_config.CODE_MAX_STRING_ARRAY_LENGTH} elements."
f" less than {self._limits.max_string_array_length} elements."
)
transformed_result[output_name] = [
@@ -326,10 +370,10 @@ class CodeNode(Node[CodeNodeData]):
f" got {type(result.get(output_name))} instead."
)
else:
if len(result[output_name]) > dify_config.CODE_MAX_OBJECT_ARRAY_LENGTH:
if len(result[output_name]) > self._limits.max_object_array_length:
raise OutputValidationError(
f"The length of output variable `{prefix}{dot}{output_name}` must be"
f" less than {dify_config.CODE_MAX_OBJECT_ARRAY_LENGTH} elements."
f" less than {self._limits.max_object_array_length} elements."
)
for i, value in enumerate(result[output_name]):
+13
View File
@@ -0,0 +1,13 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class CodeNodeLimits:
max_string_length: int
max_number: int | float
min_number: int | float
max_precision: int
max_depth: int
max_number_array_length: int
max_string_array_length: int
max_object_array_length: int
+35
View File
@@ -1,10 +1,16 @@
from collections.abc import Sequence
from typing import TYPE_CHECKING, final
from typing_extensions import override
from configs import dify_config
from core.helper.code_executor.code_executor import CodeExecutor
from core.helper.code_executor.code_node_provider import CodeNodeProvider
from core.workflow.enums import NodeType
from core.workflow.graph import NodeFactory
from core.workflow.nodes.base.node import Node
from core.workflow.nodes.code.code_node import CodeNode
from core.workflow.nodes.code.limits import CodeNodeLimits
from libs.typing import is_str, is_str_dict
from .node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING
@@ -27,9 +33,27 @@ class DifyNodeFactory(NodeFactory):
self,
graph_init_params: "GraphInitParams",
graph_runtime_state: "GraphRuntimeState",
*,
code_executor: type[CodeExecutor] | None = None,
code_providers: Sequence[type[CodeNodeProvider]] | None = None,
code_limits: CodeNodeLimits | None = None,
) -> None:
self.graph_init_params = graph_init_params
self.graph_runtime_state = graph_runtime_state
self._code_executor: type[CodeExecutor] = code_executor or CodeExecutor
self._code_providers: tuple[type[CodeNodeProvider], ...] = (
tuple(code_providers) if code_providers else CodeNode.default_code_providers()
)
self._code_limits = code_limits or CodeNodeLimits(
max_string_length=dify_config.CODE_MAX_STRING_LENGTH,
max_number=dify_config.CODE_MAX_NUMBER,
min_number=dify_config.CODE_MIN_NUMBER,
max_precision=dify_config.CODE_MAX_PRECISION,
max_depth=dify_config.CODE_MAX_DEPTH,
max_number_array_length=dify_config.CODE_MAX_NUMBER_ARRAY_LENGTH,
max_string_array_length=dify_config.CODE_MAX_STRING_ARRAY_LENGTH,
max_object_array_length=dify_config.CODE_MAX_OBJECT_ARRAY_LENGTH,
)
@override
def create_node(self, node_config: dict[str, object]) -> Node:
@@ -72,6 +96,17 @@ class DifyNodeFactory(NodeFactory):
raise ValueError(f"No latest version class found for node type: {node_type}")
# Create node instance
if node_type == NodeType.CODE:
return CodeNode(
id=node_id,
config=node_config,
graph_init_params=self.graph_init_params,
graph_runtime_state=self.graph_runtime_state,
code_executor=self._code_executor,
code_providers=self._code_providers,
code_limits=self._code_limits,
)
return node_class(
id=node_id,
config=node_config,
+321 -219
View File
@@ -1,236 +1,338 @@
from flask_restx import Namespace, fields
from __future__ import annotations
from fields.member_fields import simple_account_fields
from libs.helper import TimestampField
from datetime import datetime
from typing import Any, TypeAlias
from .raws import FilesContainedField
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from core.file import File
JSONValue: TypeAlias = Any
class MessageTextField(fields.Raw):
def format(self, value):
return value[0]["text"] if value else ""
class ResponseModel(BaseModel):
model_config = ConfigDict(
from_attributes=True,
extra="ignore",
populate_by_name=True,
serialize_by_alias=True,
protected_namespaces=(),
)
feedback_fields = {
"rating": fields.String,
"content": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account": fields.Nested(simple_account_fields, allow_null=True),
}
class MessageFile(ResponseModel):
id: str
filename: str
type: str
url: str | None = None
mime_type: str | None = None
size: int | None = None
transfer_method: str
belongs_to: str | None = None
upload_file_id: str | None = None
annotation_fields = {
"id": fields.String,
"question": fields.String,
"content": fields.String,
"account": fields.Nested(simple_account_fields, allow_null=True),
"created_at": TimestampField,
}
annotation_hit_history_fields = {
"annotation_id": fields.String(attribute="id"),
"annotation_create_account": fields.Nested(simple_account_fields, allow_null=True),
"created_at": TimestampField,
}
message_file_fields = {
"id": fields.String,
"filename": fields.String,
"type": fields.String,
"url": fields.String,
"mime_type": fields.String,
"size": fields.Integer,
"transfer_method": fields.String,
"belongs_to": fields.String(default="user"),
"upload_file_id": fields.String(default=None),
}
@field_validator("transfer_method", mode="before")
@classmethod
def _normalize_transfer_method(cls, value: object) -> str:
if isinstance(value, str):
return value
return str(value)
def build_message_file_model(api_or_ns: Namespace):
"""Build the message file fields for the API or Namespace."""
return api_or_ns.model("MessageFile", message_file_fields)
class SimpleConversation(ResponseModel):
id: str
name: str
inputs: dict[str, JSONValue]
status: str
introduction: str | None = None
created_at: int | None = None
updated_at: int | None = None
@field_validator("inputs", mode="before")
@classmethod
def _normalize_inputs(cls, value: JSONValue) -> JSONValue:
return format_files_contained(value)
@field_validator("created_at", "updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return to_timestamp(value)
return value
agent_thought_fields = {
"id": fields.String,
"chain_id": fields.String,
"message_id": fields.String,
"position": fields.Integer,
"thought": fields.String,
"tool": fields.String,
"tool_labels": fields.Raw,
"tool_input": fields.String,
"created_at": TimestampField,
"observation": fields.String,
"files": fields.List(fields.String),
}
message_detail_fields = {
"id": fields.String,
"conversation_id": fields.String,
"inputs": FilesContainedField,
"query": fields.String,
"message": fields.Raw,
"message_tokens": fields.Integer,
"answer": fields.String(attribute="re_sign_file_url_answer"),
"answer_tokens": fields.Integer,
"provider_response_latency": fields.Float,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account_id": fields.String,
"feedbacks": fields.List(fields.Nested(feedback_fields)),
"workflow_run_id": fields.String,
"annotation": fields.Nested(annotation_fields, allow_null=True),
"annotation_hit_history": fields.Nested(annotation_hit_history_fields, allow_null=True),
"created_at": TimestampField,
"agent_thoughts": fields.List(fields.Nested(agent_thought_fields)),
"message_files": fields.List(fields.Nested(message_file_fields)),
"metadata": fields.Raw(attribute="message_metadata_dict"),
"status": fields.String,
"error": fields.String,
"parent_message_id": fields.String,
}
feedback_stat_fields = {"like": fields.Integer, "dislike": fields.Integer}
status_count_fields = {"success": fields.Integer, "failed": fields.Integer, "partial_success": fields.Integer}
model_config_fields = {
"opening_statement": fields.String,
"suggested_questions": fields.Raw,
"model": fields.Raw,
"user_input_form": fields.Raw,
"pre_prompt": fields.String,
"agent_mode": fields.Raw,
}
simple_model_config_fields = {
"model": fields.Raw(attribute="model_dict"),
"pre_prompt": fields.String,
}
simple_message_detail_fields = {
"inputs": FilesContainedField,
"query": fields.String,
"message": MessageTextField,
"answer": fields.String,
}
conversation_fields = {
"id": fields.String,
"status": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_end_user_session_id": fields.String(),
"from_account_id": fields.String,
"from_account_name": fields.String,
"read_at": TimestampField,
"created_at": TimestampField,
"updated_at": TimestampField,
"annotation": fields.Nested(annotation_fields, allow_null=True),
"model_config": fields.Nested(simple_model_config_fields),
"user_feedback_stats": fields.Nested(feedback_stat_fields),
"admin_feedback_stats": fields.Nested(feedback_stat_fields),
"message": fields.Nested(simple_message_detail_fields, attribute="first_message"),
}
conversation_pagination_fields = {
"page": fields.Integer,
"limit": fields.Integer(attribute="per_page"),
"total": fields.Integer,
"has_more": fields.Boolean(attribute="has_next"),
"data": fields.List(fields.Nested(conversation_fields), attribute="items"),
}
conversation_message_detail_fields = {
"id": fields.String,
"status": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account_id": fields.String,
"created_at": TimestampField,
"model_config": fields.Nested(model_config_fields),
"message": fields.Nested(message_detail_fields, attribute="first_message"),
}
conversation_with_summary_fields = {
"id": fields.String,
"status": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_end_user_session_id": fields.String,
"from_account_id": fields.String,
"from_account_name": fields.String,
"name": fields.String,
"summary": fields.String(attribute="summary_or_query"),
"read_at": TimestampField,
"created_at": TimestampField,
"updated_at": TimestampField,
"annotated": fields.Boolean,
"model_config": fields.Nested(simple_model_config_fields),
"message_count": fields.Integer,
"user_feedback_stats": fields.Nested(feedback_stat_fields),
"admin_feedback_stats": fields.Nested(feedback_stat_fields),
"status_count": fields.Nested(status_count_fields),
}
conversation_with_summary_pagination_fields = {
"page": fields.Integer,
"limit": fields.Integer(attribute="per_page"),
"total": fields.Integer,
"has_more": fields.Boolean(attribute="has_next"),
"data": fields.List(fields.Nested(conversation_with_summary_fields), attribute="items"),
}
conversation_detail_fields = {
"id": fields.String,
"status": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account_id": fields.String,
"created_at": TimestampField,
"updated_at": TimestampField,
"annotated": fields.Boolean,
"introduction": fields.String,
"model_config": fields.Nested(model_config_fields),
"message_count": fields.Integer,
"user_feedback_stats": fields.Nested(feedback_stat_fields),
"admin_feedback_stats": fields.Nested(feedback_stat_fields),
}
simple_conversation_fields = {
"id": fields.String,
"name": fields.String,
"inputs": FilesContainedField,
"status": fields.String,
"introduction": fields.String,
"created_at": TimestampField,
"updated_at": TimestampField,
}
conversation_delete_fields = {
"result": fields.String,
}
conversation_infinite_scroll_pagination_fields = {
"limit": fields.Integer,
"has_more": fields.Boolean,
"data": fields.List(fields.Nested(simple_conversation_fields)),
}
class ConversationInfiniteScrollPagination(ResponseModel):
limit: int
has_more: bool
data: list[SimpleConversation]
def build_conversation_infinite_scroll_pagination_model(api_or_ns: Namespace):
"""Build the conversation infinite scroll pagination model for the API or Namespace."""
simple_conversation_model = build_simple_conversation_model(api_or_ns)
copied_fields = conversation_infinite_scroll_pagination_fields.copy()
copied_fields["data"] = fields.List(fields.Nested(simple_conversation_model))
return api_or_ns.model("ConversationInfiniteScrollPagination", copied_fields)
class ConversationDelete(ResponseModel):
result: str
def build_conversation_delete_model(api_or_ns: Namespace):
"""Build the conversation delete model for the API or Namespace."""
return api_or_ns.model("ConversationDelete", conversation_delete_fields)
class ResultResponse(ResponseModel):
result: str
def build_simple_conversation_model(api_or_ns: Namespace):
"""Build the simple conversation model for the API or Namespace."""
return api_or_ns.model("SimpleConversation", simple_conversation_fields)
class SimpleAccount(ResponseModel):
id: str
name: str
email: str
class Feedback(ResponseModel):
rating: str
content: str | None = None
from_source: str
from_end_user_id: str | None = None
from_account: SimpleAccount | None = None
class Annotation(ResponseModel):
id: str
question: str | None = None
content: str
account: SimpleAccount | None = None
created_at: int | None = None
@field_validator("created_at", mode="before")
@classmethod
def _normalize_created_at(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return to_timestamp(value)
return value
class AnnotationHitHistory(ResponseModel):
annotation_id: str
annotation_create_account: SimpleAccount | None = None
created_at: int | None = None
@field_validator("created_at", mode="before")
@classmethod
def _normalize_created_at(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return to_timestamp(value)
return value
class AgentThought(ResponseModel):
id: str
chain_id: str | None = None
message_chain_id: str | None = Field(default=None, exclude=True, validation_alias="message_chain_id")
message_id: str
position: int
thought: str | None = None
tool: str | None = None
tool_labels: JSONValue
tool_input: str | None = None
created_at: int | None = None
observation: str | None = None
files: list[str]
@field_validator("created_at", mode="before")
@classmethod
def _normalize_created_at(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return to_timestamp(value)
return value
@model_validator(mode="after")
def _fallback_chain_id(self):
if self.chain_id is None and self.message_chain_id:
self.chain_id = self.message_chain_id
return self
class MessageDetail(ResponseModel):
id: str
conversation_id: str
inputs: dict[str, JSONValue]
query: str
message: JSONValue
message_tokens: int
answer: str
answer_tokens: int
provider_response_latency: float
from_source: str
from_end_user_id: str | None = None
from_account_id: str | None = None
feedbacks: list[Feedback]
workflow_run_id: str | None = None
annotation: Annotation | None = None
annotation_hit_history: AnnotationHitHistory | None = None
created_at: int | None = None
agent_thoughts: list[AgentThought]
message_files: list[MessageFile]
metadata: JSONValue
status: str
error: str | None = None
parent_message_id: str | None = None
@field_validator("inputs", mode="before")
@classmethod
def _normalize_inputs(cls, value: JSONValue) -> JSONValue:
return format_files_contained(value)
@field_validator("created_at", mode="before")
@classmethod
def _normalize_created_at(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return to_timestamp(value)
return value
class FeedbackStat(ResponseModel):
like: int
dislike: int
class StatusCount(ResponseModel):
success: int
failed: int
partial_success: int
class ModelConfig(ResponseModel):
opening_statement: str | None = None
suggested_questions: JSONValue | None = None
model: JSONValue | None = None
user_input_form: JSONValue | None = None
pre_prompt: str | None = None
agent_mode: JSONValue | None = None
class SimpleModelConfig(ResponseModel):
model: JSONValue | None = None
pre_prompt: str | None = None
class SimpleMessageDetail(ResponseModel):
inputs: dict[str, JSONValue]
query: str
message: str
answer: str
@field_validator("inputs", mode="before")
@classmethod
def _normalize_inputs(cls, value: JSONValue) -> JSONValue:
return format_files_contained(value)
class Conversation(ResponseModel):
id: str
status: str
from_source: str
from_end_user_id: str | None = None
from_end_user_session_id: str | None = None
from_account_id: str | None = None
from_account_name: str | None = None
read_at: int | None = None
created_at: int | None = None
updated_at: int | None = None
annotation: Annotation | None = None
model_config_: SimpleModelConfig | None = Field(default=None, alias="model_config")
user_feedback_stats: FeedbackStat | None = None
admin_feedback_stats: FeedbackStat | None = None
message: SimpleMessageDetail | None = None
class ConversationPagination(ResponseModel):
page: int
limit: int
total: int
has_more: bool
data: list[Conversation]
class ConversationMessageDetail(ResponseModel):
id: str
status: str
from_source: str
from_end_user_id: str | None = None
from_account_id: str | None = None
created_at: int | None = None
model_config_: ModelConfig | None = Field(default=None, alias="model_config")
message: MessageDetail | None = None
class ConversationWithSummary(ResponseModel):
id: str
status: str
from_source: str
from_end_user_id: str | None = None
from_end_user_session_id: str | None = None
from_account_id: str | None = None
from_account_name: str | None = None
name: str
summary: str
read_at: int | None = None
created_at: int | None = None
updated_at: int | None = None
annotated: bool
model_config_: SimpleModelConfig | None = Field(default=None, alias="model_config")
message_count: int
user_feedback_stats: FeedbackStat | None = None
admin_feedback_stats: FeedbackStat | None = None
status_count: StatusCount | None = None
class ConversationWithSummaryPagination(ResponseModel):
page: int
limit: int
total: int
has_more: bool
data: list[ConversationWithSummary]
class ConversationDetail(ResponseModel):
id: str
status: str
from_source: str
from_end_user_id: str | None = None
from_account_id: str | None = None
created_at: int | None = None
updated_at: int | None = None
annotated: bool
introduction: str | None = None
model_config_: ModelConfig | None = Field(default=None, alias="model_config")
message_count: int
user_feedback_stats: FeedbackStat | None = None
admin_feedback_stats: FeedbackStat | None = None
def to_timestamp(value: datetime | None) -> int | None:
if value is None:
return None
return int(value.timestamp())
def format_files_contained(value: JSONValue) -> JSONValue:
if isinstance(value, File):
return value.model_dump()
if isinstance(value, dict):
return {k: format_files_contained(v) for k, v in value.items()}
if isinstance(value, list):
return [format_files_contained(v) for v in value]
return value
def message_text(value: JSONValue) -> str:
if isinstance(value, list) and value:
first = value[0]
if isinstance(first, dict):
text = first.get("text")
if isinstance(text, str):
return text
return ""
def extract_model_config(value: object | None) -> dict[str, JSONValue]:
if value is None:
return {}
if isinstance(value, dict):
return value
if hasattr(value, "to_dict"):
return value.to_dict()
return {}
+124 -64
View File
@@ -1,77 +1,137 @@
from flask_restx import Namespace, fields
from __future__ import annotations
from fields.conversation_fields import message_file_fields
from libs.helper import TimestampField
from datetime import datetime
from typing import TypeAlias
from .raws import FilesContainedField
from pydantic import BaseModel, ConfigDict, Field, field_validator
feedback_fields = {
"rating": fields.String,
}
from core.file import File
from fields.conversation_fields import AgentThought, JSONValue, MessageFile
JSONValueType: TypeAlias = JSONValue
def build_feedback_model(api_or_ns: Namespace):
"""Build the feedback model for the API or Namespace."""
return api_or_ns.model("Feedback", feedback_fields)
class ResponseModel(BaseModel):
model_config = ConfigDict(from_attributes=True, extra="ignore")
agent_thought_fields = {
"id": fields.String,
"chain_id": fields.String,
"message_id": fields.String,
"position": fields.Integer,
"thought": fields.String,
"tool": fields.String,
"tool_labels": fields.Raw,
"tool_input": fields.String,
"created_at": TimestampField,
"observation": fields.String,
"files": fields.List(fields.String),
}
class SimpleFeedback(ResponseModel):
rating: str | None = None
def build_agent_thought_model(api_or_ns: Namespace):
"""Build the agent thought model for the API or Namespace."""
return api_or_ns.model("AgentThought", agent_thought_fields)
class RetrieverResource(ResponseModel):
id: str
message_id: str
position: int
dataset_id: str | None = None
dataset_name: str | None = None
document_id: str | None = None
document_name: str | None = None
data_source_type: str | None = None
segment_id: str | None = None
score: float | None = None
hit_count: int | None = None
word_count: int | None = None
segment_position: int | None = None
index_node_hash: str | None = None
content: str | None = None
created_at: int | None = None
@field_validator("created_at", mode="before")
@classmethod
def _normalize_created_at(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return to_timestamp(value)
return value
retriever_resource_fields = {
"id": fields.String,
"message_id": fields.String,
"position": fields.Integer,
"dataset_id": fields.String,
"dataset_name": fields.String,
"document_id": fields.String,
"document_name": fields.String,
"data_source_type": fields.String,
"segment_id": fields.String,
"score": fields.Float,
"hit_count": fields.Integer,
"word_count": fields.Integer,
"segment_position": fields.Integer,
"index_node_hash": fields.String,
"content": fields.String,
"created_at": TimestampField,
}
class MessageListItem(ResponseModel):
id: str
conversation_id: str
parent_message_id: str | None = None
inputs: dict[str, JSONValueType]
query: str
answer: str = Field(validation_alias="re_sign_file_url_answer")
feedback: SimpleFeedback | None = Field(default=None, validation_alias="user_feedback")
retriever_resources: list[RetrieverResource]
created_at: int | None = None
agent_thoughts: list[AgentThought]
message_files: list[MessageFile]
status: str
error: str | None = None
message_fields = {
"id": fields.String,
"conversation_id": fields.String,
"parent_message_id": fields.String,
"inputs": FilesContainedField,
"query": fields.String,
"answer": fields.String(attribute="re_sign_file_url_answer"),
"feedback": fields.Nested(feedback_fields, attribute="user_feedback", allow_null=True),
"retriever_resources": fields.List(fields.Nested(retriever_resource_fields)),
"created_at": TimestampField,
"agent_thoughts": fields.List(fields.Nested(agent_thought_fields)),
"message_files": fields.List(fields.Nested(message_file_fields)),
"status": fields.String,
"error": fields.String,
}
@field_validator("inputs", mode="before")
@classmethod
def _normalize_inputs(cls, value: JSONValueType) -> JSONValueType:
return format_files_contained(value)
message_infinite_scroll_pagination_fields = {
"limit": fields.Integer,
"has_more": fields.Boolean,
"data": fields.List(fields.Nested(message_fields)),
}
@field_validator("created_at", mode="before")
@classmethod
def _normalize_created_at(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return to_timestamp(value)
return value
class WebMessageListItem(MessageListItem):
metadata: JSONValueType | None = Field(default=None, validation_alias="message_metadata_dict")
class MessageInfiniteScrollPagination(ResponseModel):
limit: int
has_more: bool
data: list[MessageListItem]
class WebMessageInfiniteScrollPagination(ResponseModel):
limit: int
has_more: bool
data: list[WebMessageListItem]
class SavedMessageItem(ResponseModel):
id: str
inputs: dict[str, JSONValueType]
query: str
answer: str
message_files: list[MessageFile]
feedback: SimpleFeedback | None = Field(default=None, validation_alias="user_feedback")
created_at: int | None = None
@field_validator("inputs", mode="before")
@classmethod
def _normalize_inputs(cls, value: JSONValueType) -> JSONValueType:
return format_files_contained(value)
@field_validator("created_at", mode="before")
@classmethod
def _normalize_created_at(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return to_timestamp(value)
return value
class SavedMessageInfiniteScrollPagination(ResponseModel):
limit: int
has_more: bool
data: list[SavedMessageItem]
class SuggestedQuestionsResponse(ResponseModel):
data: list[str]
def to_timestamp(value: datetime | None) -> int | None:
if value is None:
return None
return int(value.timestamp())
def format_files_contained(value: JSONValueType) -> JSONValueType:
if isinstance(value, File):
return value.model_dump()
if isinstance(value, dict):
return {k: format_files_contained(v) for k, v in value.items()}
if isinstance(value, list):
return [format_files_contained(v) for v in value]
return value
@@ -10,6 +10,7 @@ from core.workflow.enums import WorkflowNodeExecutionStatus
from core.workflow.graph import Graph
from core.workflow.node_events import NodeRunResult
from core.workflow.nodes.code.code_node import CodeNode
from core.workflow.nodes.code.limits import CodeNodeLimits
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
@@ -67,6 +68,16 @@ def init_code_node(code_config: dict):
config=code_config,
graph_init_params=init_params,
graph_runtime_state=graph_runtime_state,
code_limits=CodeNodeLimits(
max_string_length=dify_config.CODE_MAX_STRING_LENGTH,
max_number=dify_config.CODE_MAX_NUMBER,
min_number=dify_config.CODE_MIN_NUMBER,
max_precision=dify_config.CODE_MAX_PRECISION,
max_depth=dify_config.CODE_MAX_DEPTH,
max_number_array_length=dify_config.CODE_MAX_NUMBER_ARRAY_LENGTH,
max_string_array_length=dify_config.CODE_MAX_STRING_ARRAY_LENGTH,
max_object_array_length=dify_config.CODE_MAX_OBJECT_ARRAY_LENGTH,
),
)
return node
@@ -0,0 +1,174 @@
"""Unit tests for controllers.web.message message list mapping."""
from __future__ import annotations
import builtins
from datetime import datetime
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
from uuid import uuid4
import pytest
from flask import Flask
from flask.views import MethodView
# Ensure flask_restx.api finds MethodView during import.
if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
def _load_controller_module():
"""Import controllers.web.message using a stub package."""
import importlib
import importlib.util
import sys
parent_module_name = "controllers.web"
module_name = f"{parent_module_name}.message"
if parent_module_name not in sys.modules:
from flask_restx import Namespace
stub = ModuleType(parent_module_name)
stub.__file__ = "controllers/web/__init__.py"
stub.__path__ = ["controllers/web"]
stub.__package__ = "controllers"
stub.__spec__ = importlib.util.spec_from_loader(parent_module_name, loader=None, is_package=True)
stub.web_ns = Namespace("web", description="Web API", path="/")
sys.modules[parent_module_name] = stub
wraps_module_name = f"{parent_module_name}.wraps"
if wraps_module_name not in sys.modules:
wraps_stub = ModuleType(wraps_module_name)
class WebApiResource:
pass
wraps_stub.WebApiResource = WebApiResource
sys.modules[wraps_module_name] = wraps_stub
return importlib.import_module(module_name)
message_module = _load_controller_module()
MessageListApi = message_module.MessageListApi
@pytest.fixture
def app() -> Flask:
app = Flask(__name__)
app.config["TESTING"] = True
return app
def test_message_list_mapping(app: Flask) -> None:
conversation_id = str(uuid4())
message_id = str(uuid4())
created_at = datetime(2024, 1, 1, 12, 0, 0)
resource_created_at = datetime(2024, 1, 1, 13, 0, 0)
thought_created_at = datetime(2024, 1, 1, 14, 0, 0)
retriever_resource_obj = SimpleNamespace(
id="res-obj",
message_id=message_id,
position=2,
dataset_id="ds-1",
dataset_name="dataset",
document_id="doc-1",
document_name="document",
data_source_type="file",
segment_id="seg-1",
score=0.9,
hit_count=1,
word_count=10,
segment_position=0,
index_node_hash="hash",
content="content",
created_at=resource_created_at,
)
agent_thought = SimpleNamespace(
id="thought-1",
chain_id=None,
message_chain_id="chain-1",
message_id=message_id,
position=1,
thought="thinking",
tool="tool",
tool_labels={"label": "value"},
tool_input="{}",
created_at=thought_created_at,
observation="observed",
files=["file-a"],
)
message_file_obj = SimpleNamespace(
id="file-obj",
filename="b.txt",
type="file",
url=None,
mime_type=None,
size=None,
transfer_method="local",
belongs_to=None,
upload_file_id=None,
)
message = SimpleNamespace(
id=message_id,
conversation_id=conversation_id,
parent_message_id=None,
inputs={"foo": "bar"},
query="hello",
re_sign_file_url_answer="answer",
user_feedback=SimpleNamespace(rating="like"),
retriever_resources=[
{"id": "res-dict", "message_id": message_id, "position": 1},
retriever_resource_obj,
],
created_at=created_at,
agent_thoughts=[agent_thought],
message_files=[
{"id": "file-dict", "filename": "a.txt", "type": "file", "transfer_method": "local"},
message_file_obj,
],
status="success",
error=None,
message_metadata_dict={"meta": "value"},
)
pagination = SimpleNamespace(limit=20, has_more=False, data=[message])
app_model = SimpleNamespace(mode="chat")
end_user = SimpleNamespace()
with (
patch.object(message_module.MessageService, "pagination_by_first_id", return_value=pagination) as mock_page,
app.test_request_context(f"/messages?conversation_id={conversation_id}&limit=20"),
):
response = MessageListApi().get(app_model, end_user)
mock_page.assert_called_once_with(app_model, end_user, conversation_id, None, 20)
assert response["limit"] == 20
assert response["has_more"] is False
assert len(response["data"]) == 1
item = response["data"][0]
assert item["id"] == message_id
assert item["conversation_id"] == conversation_id
assert item["inputs"] == {"foo": "bar"}
assert item["answer"] == "answer"
assert item["feedback"]["rating"] == "like"
assert item["metadata"] == {"meta": "value"}
assert item["created_at"] == int(created_at.timestamp())
assert item["retriever_resources"][0]["id"] == "res-dict"
assert item["retriever_resources"][1]["id"] == "res-obj"
assert item["retriever_resources"][1]["created_at"] == int(resource_created_at.timestamp())
assert item["agent_thoughts"][0]["chain_id"] == "chain-1"
assert item["agent_thoughts"][0]["created_at"] == int(thought_created_at.timestamp())
assert item["message_files"][0]["id"] == "file-dict"
assert item["message_files"][1]["id"] == "file-obj"
@@ -73,6 +73,7 @@ import pytest
from core.rag.datasource.retrieval_service import RetrievalService
from core.rag.models.document import Document
from core.rag.retrieval.dataset_retrieval import DatasetRetrieval
from core.rag.retrieval.retrieval_methods import RetrievalMethod
from models.dataset import Dataset
@@ -1518,6 +1519,282 @@ class TestRetrievalService:
call_kwargs = mock_retrieve.call_args.kwargs
assert call_kwargs["reranking_model"] == reranking_model
# ==================== Multiple Retrieve Thread Tests ====================
@patch("core.rag.retrieval.dataset_retrieval.DataPostProcessor")
@patch("core.rag.retrieval.dataset_retrieval.DatasetRetrieval._retriever")
def test_multiple_retrieve_thread_skips_second_reranking_with_single_dataset(
self, mock_retriever, mock_data_processor_class, mock_flask_app, mock_dataset
):
"""
Test that _multiple_retrieve_thread skips second reranking when dataset_count is 1.
When there is only one dataset, the second reranking is unnecessary
because the documents are already ranked from the first retrieval.
This optimization avoids the overhead of reranking when it won't
provide any benefit.
Verifies:
- DataPostProcessor is NOT called when dataset_count == 1
- Documents are still added to all_documents
- Standard scoring logic is applied instead
"""
# Arrange
dataset_retrieval = DatasetRetrieval()
tenant_id = str(uuid4())
# Create test documents
doc1 = Document(
page_content="Test content 1",
metadata={"doc_id": "doc1", "score": 0.9, "document_id": str(uuid4()), "dataset_id": mock_dataset.id},
provider="dify",
)
doc2 = Document(
page_content="Test content 2",
metadata={"doc_id": "doc2", "score": 0.8, "document_id": str(uuid4()), "dataset_id": mock_dataset.id},
provider="dify",
)
# Mock _retriever to return documents
def side_effect_retriever(
flask_app, dataset_id, query, top_k, all_documents, document_ids_filter, metadata_condition, attachment_ids
):
all_documents.extend([doc1, doc2])
mock_retriever.side_effect = side_effect_retriever
# Set up dataset with high_quality indexing
mock_dataset.indexing_technique = "high_quality"
all_documents = []
# Act - Call with dataset_count = 1
dataset_retrieval._multiple_retrieve_thread(
flask_app=mock_flask_app,
available_datasets=[mock_dataset],
metadata_condition=None,
metadata_filter_document_ids=None,
all_documents=all_documents,
tenant_id=tenant_id,
reranking_enable=True,
reranking_mode="reranking_model",
reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"},
weights=None,
top_k=5,
score_threshold=0.5,
query="test query",
attachment_id=None,
dataset_count=1, # Single dataset - should skip second reranking
)
# Assert
# DataPostProcessor should NOT be called (second reranking skipped)
mock_data_processor_class.assert_not_called()
# Documents should still be added to all_documents
assert len(all_documents) == 2
assert all_documents[0].page_content == "Test content 1"
assert all_documents[1].page_content == "Test content 2"
@patch("core.rag.retrieval.dataset_retrieval.DataPostProcessor")
@patch("core.rag.retrieval.dataset_retrieval.DatasetRetrieval._retriever")
@patch("core.rag.retrieval.dataset_retrieval.DatasetRetrieval.calculate_vector_score")
def test_multiple_retrieve_thread_performs_second_reranking_with_multiple_datasets(
self, mock_calculate_vector_score, mock_retriever, mock_data_processor_class, mock_flask_app, mock_dataset
):
"""
Test that _multiple_retrieve_thread performs second reranking when dataset_count > 1.
When there are multiple datasets, the second reranking is necessary
to merge and re-rank results from different datasets. This ensures
the most relevant documents across all datasets are returned.
Verifies:
- DataPostProcessor IS called when dataset_count > 1
- Reranking is applied with correct parameters
- Documents are processed correctly
"""
# Arrange
dataset_retrieval = DatasetRetrieval()
tenant_id = str(uuid4())
# Create test documents
doc1 = Document(
page_content="Test content 1",
metadata={"doc_id": "doc1", "score": 0.7, "document_id": str(uuid4()), "dataset_id": mock_dataset.id},
provider="dify",
)
doc2 = Document(
page_content="Test content 2",
metadata={"doc_id": "doc2", "score": 0.6, "document_id": str(uuid4()), "dataset_id": mock_dataset.id},
provider="dify",
)
# Mock _retriever to return documents
def side_effect_retriever(
flask_app, dataset_id, query, top_k, all_documents, document_ids_filter, metadata_condition, attachment_ids
):
all_documents.extend([doc1, doc2])
mock_retriever.side_effect = side_effect_retriever
# Set up dataset with high_quality indexing
mock_dataset.indexing_technique = "high_quality"
# Mock DataPostProcessor instance and its invoke method
mock_processor_instance = Mock()
# Simulate reranking - return documents in reversed order with updated scores
reranked_docs = [
Document(
page_content="Test content 2",
metadata={"doc_id": "doc2", "score": 0.95, "document_id": str(uuid4()), "dataset_id": mock_dataset.id},
provider="dify",
),
Document(
page_content="Test content 1",
metadata={"doc_id": "doc1", "score": 0.85, "document_id": str(uuid4()), "dataset_id": mock_dataset.id},
provider="dify",
),
]
mock_processor_instance.invoke.return_value = reranked_docs
mock_data_processor_class.return_value = mock_processor_instance
all_documents = []
# Create second dataset
mock_dataset2 = Mock(spec=Dataset)
mock_dataset2.id = str(uuid4())
mock_dataset2.indexing_technique = "high_quality"
mock_dataset2.provider = "dify"
# Act - Call with dataset_count = 2
dataset_retrieval._multiple_retrieve_thread(
flask_app=mock_flask_app,
available_datasets=[mock_dataset, mock_dataset2],
metadata_condition=None,
metadata_filter_document_ids=None,
all_documents=all_documents,
tenant_id=tenant_id,
reranking_enable=True,
reranking_mode="reranking_model",
reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"},
weights=None,
top_k=5,
score_threshold=0.5,
query="test query",
attachment_id=None,
dataset_count=2, # Multiple datasets - should perform second reranking
)
# Assert
# DataPostProcessor SHOULD be called (second reranking performed)
mock_data_processor_class.assert_called_once_with(
tenant_id,
"reranking_model",
{"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"},
None,
False,
)
# Verify invoke was called with correct parameters
mock_processor_instance.invoke.assert_called_once()
# Documents should be added to all_documents after reranking
assert len(all_documents) == 2
# The reranked order should be reflected
assert all_documents[0].page_content == "Test content 2"
assert all_documents[1].page_content == "Test content 1"
@patch("core.rag.retrieval.dataset_retrieval.DataPostProcessor")
@patch("core.rag.retrieval.dataset_retrieval.DatasetRetrieval._retriever")
@patch("core.rag.retrieval.dataset_retrieval.DatasetRetrieval.calculate_vector_score")
def test_multiple_retrieve_thread_single_dataset_uses_standard_scoring(
self, mock_calculate_vector_score, mock_retriever, mock_data_processor_class, mock_flask_app, mock_dataset
):
"""
Test that _multiple_retrieve_thread uses standard scoring when dataset_count is 1
and reranking is enabled.
When there's only one dataset, instead of using DataPostProcessor,
the method should fall through to the standard scoring logic
(calculate_vector_score for high_quality datasets).
Verifies:
- DataPostProcessor is NOT called
- calculate_vector_score IS called for high_quality indexing
- Documents are scored correctly
"""
# Arrange
dataset_retrieval = DatasetRetrieval()
tenant_id = str(uuid4())
# Create test documents
doc1 = Document(
page_content="Test content 1",
metadata={"doc_id": "doc1", "score": 0.9, "document_id": str(uuid4()), "dataset_id": mock_dataset.id},
provider="dify",
)
doc2 = Document(
page_content="Test content 2",
metadata={"doc_id": "doc2", "score": 0.8, "document_id": str(uuid4()), "dataset_id": mock_dataset.id},
provider="dify",
)
# Mock _retriever to return documents
def side_effect_retriever(
flask_app, dataset_id, query, top_k, all_documents, document_ids_filter, metadata_condition, attachment_ids
):
all_documents.extend([doc1, doc2])
mock_retriever.side_effect = side_effect_retriever
# Set up dataset with high_quality indexing
mock_dataset.indexing_technique = "high_quality"
# Mock calculate_vector_score to return scored documents
scored_docs = [
Document(
page_content="Test content 1",
metadata={"doc_id": "doc1", "score": 0.95, "document_id": str(uuid4()), "dataset_id": mock_dataset.id},
provider="dify",
),
]
mock_calculate_vector_score.return_value = scored_docs
all_documents = []
# Act - Call with dataset_count = 1
dataset_retrieval._multiple_retrieve_thread(
flask_app=mock_flask_app,
available_datasets=[mock_dataset],
metadata_condition=None,
metadata_filter_document_ids=None,
all_documents=all_documents,
tenant_id=tenant_id,
reranking_enable=True, # Reranking enabled but should be skipped for single dataset
reranking_mode="reranking_model",
reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"},
weights=None,
top_k=5,
score_threshold=0.5,
query="test query",
attachment_id=None,
dataset_count=1,
)
# Assert
# DataPostProcessor should NOT be called
mock_data_processor_class.assert_not_called()
# calculate_vector_score SHOULD be called for high_quality datasets
mock_calculate_vector_score.assert_called_once()
call_args = mock_calculate_vector_score.call_args
assert call_args[0][1] == 5 # top_k
# Documents should be added after standard scoring
assert len(all_documents) == 1
assert all_documents[0].page_content == "Test content 1"
class TestRetrievalMethods:
"""
@@ -103,13 +103,25 @@ class MockNodeFactory(DifyNodeFactory):
# Create mock node instance
mock_class = self._mock_node_types[node_type]
mock_instance = mock_class(
id=node_id,
config=node_config,
graph_init_params=self.graph_init_params,
graph_runtime_state=self.graph_runtime_state,
mock_config=self.mock_config,
)
if node_type == NodeType.CODE:
mock_instance = mock_class(
id=node_id,
config=node_config,
graph_init_params=self.graph_init_params,
graph_runtime_state=self.graph_runtime_state,
mock_config=self.mock_config,
code_executor=self._code_executor,
code_providers=self._code_providers,
code_limits=self._code_limits,
)
else:
mock_instance = mock_class(
id=node_id,
config=node_config,
graph_init_params=self.graph_init_params,
graph_runtime_state=self.graph_runtime_state,
mock_config=self.mock_config,
)
return mock_instance
@@ -40,12 +40,14 @@ class MockNodeMixin:
graph_init_params: "GraphInitParams",
graph_runtime_state: "GraphRuntimeState",
mock_config: Optional["MockConfig"] = None,
**kwargs: Any,
):
super().__init__(
id=id,
config=config,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
**kwargs,
)
self.mock_config = mock_config
@@ -5,11 +5,24 @@ This module tests the functionality of MockTemplateTransformNode and MockCodeNod
to ensure they work correctly with the TableTestRunner.
"""
from configs import dify_config
from core.workflow.enums import NodeType, WorkflowNodeExecutionStatus
from core.workflow.nodes.code.limits import CodeNodeLimits
from tests.unit_tests.core.workflow.graph_engine.test_mock_config import MockConfig, MockConfigBuilder, NodeMockConfig
from tests.unit_tests.core.workflow.graph_engine.test_mock_factory import MockNodeFactory
from tests.unit_tests.core.workflow.graph_engine.test_mock_nodes import MockCodeNode, MockTemplateTransformNode
DEFAULT_CODE_LIMITS = CodeNodeLimits(
max_string_length=dify_config.CODE_MAX_STRING_LENGTH,
max_number=dify_config.CODE_MAX_NUMBER,
min_number=dify_config.CODE_MIN_NUMBER,
max_precision=dify_config.CODE_MAX_PRECISION,
max_depth=dify_config.CODE_MAX_DEPTH,
max_number_array_length=dify_config.CODE_MAX_NUMBER_ARRAY_LENGTH,
max_string_array_length=dify_config.CODE_MAX_STRING_ARRAY_LENGTH,
max_object_array_length=dify_config.CODE_MAX_OBJECT_ARRAY_LENGTH,
)
class TestMockTemplateTransformNode:
"""Test cases for MockTemplateTransformNode."""
@@ -306,6 +319,7 @@ class TestMockCodeNode:
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
mock_config=mock_config,
code_limits=DEFAULT_CODE_LIMITS,
)
# Run the node
@@ -370,6 +384,7 @@ class TestMockCodeNode:
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
mock_config=mock_config,
code_limits=DEFAULT_CODE_LIMITS,
)
# Run the node
@@ -438,6 +453,7 @@ class TestMockCodeNode:
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
mock_config=mock_config,
code_limits=DEFAULT_CODE_LIMITS,
)
# Run the node
@@ -1,3 +1,4 @@
from configs import dify_config
from core.helper.code_executor.code_executor import CodeLanguage
from core.variables.types import SegmentType
from core.workflow.nodes.code.code_node import CodeNode
@@ -7,6 +8,18 @@ from core.workflow.nodes.code.exc import (
DepthLimitError,
OutputValidationError,
)
from core.workflow.nodes.code.limits import CodeNodeLimits
CodeNode._limits = CodeNodeLimits(
max_string_length=dify_config.CODE_MAX_STRING_LENGTH,
max_number=dify_config.CODE_MAX_NUMBER,
min_number=dify_config.CODE_MIN_NUMBER,
max_precision=dify_config.CODE_MAX_PRECISION,
max_depth=dify_config.CODE_MAX_DEPTH,
max_number_array_length=dify_config.CODE_MAX_NUMBER_ARRAY_LENGTH,
max_string_array_length=dify_config.CODE_MAX_STRING_ARRAY_LENGTH,
max_object_array_length=dify_config.CODE_MAX_OBJECT_ARRAY_LENGTH,
)
class TestCodeNodeExceptions:
Generated
+3 -3
View File
@@ -1953,14 +1953,14 @@ wheels = [
[[package]]
name = "fickling"
version = "0.1.5"
version = "0.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "stdlib-list" },
]
sdist = { url = "https://files.pythonhosted.org/packages/41/94/0d0ce455952c036cfee235637f786c1d1d07d1b90f6a4dfb50e0eff929d6/fickling-0.1.5.tar.gz", hash = "sha256:92f9b49e717fa8dbc198b4b7b685587adb652d85aa9ede8131b3e44494efca05", size = 282462, upload-time = "2025-11-18T05:04:30.748Z" }
sdist = { url = "https://files.pythonhosted.org/packages/07/ab/7571453f9365c17c047b5a7b7e82692a7f6be51203f295030886758fd57a/fickling-0.1.6.tar.gz", hash = "sha256:03cb5d7bd09f9169c7583d2079fad4b3b88b25f865ed0049172e5cb68582311d", size = 284033, upload-time = "2025-12-15T18:14:58.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/a7/d25912b2e3a5b0a37e6f460050bbc396042b5906a6563a1962c484abc3c6/fickling-0.1.5-py3-none-any.whl", hash = "sha256:6aed7270bfa276e188b0abe043a27b3a042129d28ec1fa6ff389bdcc5ad178bb", size = 46240, upload-time = "2025-11-18T05:04:29.048Z" },
{ url = "https://files.pythonhosted.org/packages/76/99/cc04258dda421bc612cdfe4be8c253f45b922f1c7f268b5a0b9962d9cd12/fickling-0.1.6-py3-none-any.whl", hash = "sha256:465d0069548bfc731bdd75a583cb4cf5a4b2666739c0f76287807d724b147ed3", size = 47922, upload-time = "2025-12-15T18:14:57.526Z" },
]
[[package]]
+4 -4
View File
@@ -233,7 +233,7 @@ NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
# You can adjust the database configuration according to your needs.
# ------------------------------
# Database type, supported values are `postgresql` and `mysql`
# Database type, supported values are `postgresql`, `mysql`, `oceanbase`, `seekdb`
DB_TYPE=postgresql
# For MySQL, only `root` user is supported for now
DB_USERNAME=postgres
@@ -533,7 +533,7 @@ SUPABASE_URL=your-server-url
# ------------------------------
# The type of vector store to use.
# Supported values are `weaviate`, `oceanbase`, `qdrant`, `milvus`, `myscale`, `relyt`, `pgvector`, `pgvecto-rs`, `chroma`, `opensearch`, `oracle`, `tencent`, `elasticsearch`, `elasticsearch-ja`, `analyticdb`, `couchbase`, `vikingdb`, `opengauss`, `tablestore`,`vastbase`,`tidb`,`tidb_on_qdrant`,`baidu`,`lindorm`,`huawei_cloud`,`upstash`, `matrixone`, `clickzetta`, `alibabacloud_mysql`, `iris`.
# Supported values are `weaviate`, `oceanbase`, `seekdb`, `qdrant`, `milvus`, `myscale`, `relyt`, `pgvector`, `pgvecto-rs`, `chroma`, `opensearch`, `oracle`, `tencent`, `elasticsearch`, `elasticsearch-ja`, `analyticdb`, `couchbase`, `vikingdb`, `opengauss`, `tablestore`, `vastbase`, `tidb`, `tidb_on_qdrant`, `baidu`, `lindorm`, `huawei_cloud`, `upstash`, `matrixone`, `clickzetta`, `alibabacloud_mysql`, `iris`.
VECTOR_STORE=weaviate
# Prefix used to create collection name in vector database
VECTOR_INDEX_NAME_PREFIX=Vector_index
@@ -544,9 +544,9 @@ WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih
WEAVIATE_GRPC_ENDPOINT=grpc://weaviate:50051
WEAVIATE_TOKENIZATION=word
# For OceanBase metadata database configuration, available when `DB_TYPE` is `mysql` and `COMPOSE_PROFILES` includes `oceanbase`.
# For OceanBase metadata database configuration, available when `DB_TYPE` is `oceanbase`.
# For OceanBase vector database configuration, available when `VECTOR_STORE` is `oceanbase`
# If you want to use OceanBase as both vector database and metadata database, you need to set `DB_TYPE` to `mysql`, `COMPOSE_PROFILES` is `oceanbase`, and set Database Configuration is the same as the vector database.
# If you want to use OceanBase as both vector database and metadata database, you need to set both `DB_TYPE` and `VECTOR_STORE` to `oceanbase`, and set Database Configuration is the same as the vector database.
# seekdb is the lite version of OceanBase and shares the connection configuration with OceanBase.
OCEANBASE_VECTOR_HOST=oceanbase
OCEANBASE_VECTOR_PORT=2881
+49
View File
@@ -54,3 +54,52 @@ http_access allow src_all
# Unless the option's size is increased, an error will occur when uploading more than two files.
client_request_buffer_max_size 100 MB
################################## Performance & Concurrency ###############################
# Increase file descriptor limit for high concurrency
max_filedescriptors 65536
# Timeout configurations for image requests
connect_timeout 30 seconds
request_timeout 2 minutes
read_timeout 2 minutes
client_lifetime 5 minutes
shutdown_lifetime 30 seconds
# Persistent connections - improve performance for multiple requests
server_persistent_connections on
client_persistent_connections on
persistent_request_timeout 30 seconds
pconn_timeout 1 minute
# Connection pool and concurrency limits
client_db on
server_idle_pconn_timeout 2 minutes
client_idle_pconn_timeout 2 minutes
# Quick abort settings - don't abort requests that are mostly done
quick_abort_min 16 KB
quick_abort_max 16 MB
quick_abort_pct 95
# Memory and cache optimization
memory_cache_mode disk
cache_mem 256 MB
maximum_object_size_in_memory 512 KB
# DNS resolver settings for better performance
dns_timeout 30 seconds
dns_retransmit_interval 5 seconds
# By default, Squid uses the system's configured DNS resolvers.
# If you need to override them, set dns_nameservers to appropriate servers
# for your environment (for example, internal/corporate DNS). The following
# is an example using public DNS and SHOULD be customized before use:
# dns_nameservers 8.8.8.8 8.8.4.4
# Logging format for better debugging
logformat dify_log %ts.%03tu %6tr %>a %Ss/%03>Hs %<st %rm %ru %[un %Sh/%<a %mt
access_log daemon:/var/log/squid/access.log dify_log
# Access log to track concurrent requests and timeouts
logfile_rotate 10
+1 -4
View File
@@ -1,6 +1,6 @@
{
"name": "dify-client",
"version": "3.0.0",
"version": "3.1.0",
"description": "This is the Node.js SDK for the Dify.AI API, which allows you to easily integrate Dify.AI into your Node.js applications.",
"type": "module",
"main": "./dist/index.js",
@@ -53,9 +53,6 @@
"publish:check": "./scripts/publish.sh --dry-run",
"publish:npm": "./scripts/publish.sh"
},
"dependencies": {
"axios": "^1.13.2"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^25.0.3",
-191
View File
@@ -7,10 +7,6 @@ settings:
importers:
.:
dependencies:
axios:
specifier: ^1.13.2
version: 1.13.2
devDependencies:
'@eslint/js':
specifier: ^9.39.2
@@ -541,12 +537,6 @@ packages:
ast-v8-to-istanbul@0.3.10:
resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
axios@1.13.2:
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -566,10 +556,6 @@ packages:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
@@ -593,10 +579,6 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
@@ -627,33 +609,9 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
esbuild@0.27.2:
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
engines: {node: '>=18'}
@@ -748,35 +706,11 @@ packages:
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
glob-parent@6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
@@ -785,26 +719,10 @@ packages:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
@@ -906,18 +824,6 @@ packages:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@@ -1016,9 +922,6 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -1675,16 +1578,6 @@ snapshots:
estree-walker: 3.0.3
js-tokens: 9.0.1
asynckit@0.4.0: {}
axios@1.13.2:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
balanced-match@1.0.2: {}
brace-expansion@1.1.12:
@@ -1703,11 +1596,6 @@ snapshots:
cac@6.7.14: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
callsites@3.1.0: {}
chai@6.2.2: {}
@@ -1727,10 +1615,6 @@ snapshots:
color-name@1.1.4: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@4.1.1: {}
concat-map@0.0.1: {}
@@ -1751,31 +1635,8 @@ snapshots:
deep-is@0.1.4: {}
delayed-stream@1.0.0: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-module-lexer@1.7.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
esbuild@0.27.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.2
@@ -1911,59 +1772,17 @@ snapshots:
flatted@3.3.3: {}
follow-redirects@1.15.11: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
glob-parent@6.0.2:
dependencies:
is-glob: 4.0.3
globals@14.0.0: {}
gopd@1.2.0: {}
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
html-escaper@2.0.2: {}
ignore@5.3.2: {}
@@ -2055,14 +1874,6 @@ snapshots:
dependencies:
semver: 7.7.3
math-intrinsics@1.1.0: {}
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
@@ -2147,8 +1958,6 @@ snapshots:
prelude-ls@1.2.1: {}
proxy-from-env@1.1.0: {}
punycode@2.3.1: {}
readdirp@4.1.2: {}
+153 -116
View File
@@ -1,4 +1,3 @@
import axios from "axios";
import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -12,17 +11,46 @@ import {
} from "../errors/dify-error";
import { HttpClient } from "./client";
// Helper to create a mock fetch response
const createMockResponse = (options = {}) => {
const {
ok = true,
status = 200,
headers = {},
body = null,
data = null,
} = options;
const headersObj = new Headers(headers);
const response = {
ok,
status,
headers: headersObj,
body,
json: vi.fn().mockResolvedValue(data),
text: vi.fn().mockResolvedValue(typeof data === 'string' ? data : JSON.stringify(data)),
blob: vi.fn().mockResolvedValue(new Blob()),
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)),
};
return response;
};
describe("HttpClient", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.useRealTimers(); // Ensure real timers are used by default
});
it("builds requests with auth headers and JSON content type", async () => {
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: { ok: true },
headers: { "x-request-id": "req" },
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn().mockResolvedValue(
createMockResponse({
status: 200,
headers: { "x-request-id": "req" },
data: { ok: true },
})
);
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" });
const response = await client.request({
@@ -32,19 +60,20 @@ describe("HttpClient", () => {
});
expect(response.requestId).toBe("req");
const config = mockRequest.mock.calls[0][0];
const [url, config] = mockFetch.mock.calls[0];
expect(config.headers.Authorization).toBe("Bearer test");
expect(config.headers["Content-Type"]).toBe("application/json");
expect(config.responseType).toBe("json");
expect(url).toContain("/chat-messages");
});
it("serializes array query params", async () => {
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: "ok",
headers: {},
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn().mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" });
await client.requestRaw({
@@ -53,21 +82,31 @@ describe("HttpClient", () => {
query: { tag_ids: ["a", "b"], limit: 2 },
});
const config = mockRequest.mock.calls[0][0];
const queryString = config.paramsSerializer.serialize({
tag_ids: ["a", "b"],
limit: 2,
});
expect(queryString).toBe("tag_ids=a&tag_ids=b&limit=2");
const [url] = mockFetch.mock.calls[0];
expect(url).toContain("tag_ids=a&tag_ids=b&limit=2");
});
it("returns SSE stream helpers", async () => {
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: Readable.from(["data: {\"text\":\"hi\"}\n\n"]),
headers: { "x-request-id": "req" },
// Create a mock web ReadableStream from Node stream data
const chunks = ['data: {"text":"hi"}\n\n'];
let index = 0;
const webStream = new ReadableStream({
pull(controller) {
if (index < chunks.length) {
controller.enqueue(new TextEncoder().encode(chunks[index++]));
} else {
controller.close();
}
},
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "x-request-id": "req" }),
body: webStream,
});
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" });
const stream = await client.requestStream({
@@ -82,12 +121,26 @@ describe("HttpClient", () => {
});
it("returns binary stream helpers", async () => {
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: Readable.from(["chunk"]),
headers: { "x-request-id": "req" },
// Create a mock web ReadableStream from Node stream data
const chunks = ["chunk"];
let index = 0;
const webStream = new ReadableStream({
pull(controller) {
if (index < chunks.length) {
controller.enqueue(new TextEncoder().encode(chunks[index++]));
} else {
controller.close();
}
},
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "x-request-id": "req" }),
body: webStream,
});
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" });
const stream = await client.requestBinaryStream({
@@ -101,12 +154,13 @@ describe("HttpClient", () => {
});
it("respects form-data headers", async () => {
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: "ok",
headers: {},
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn().mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" });
const form = {
@@ -120,7 +174,7 @@ describe("HttpClient", () => {
data: form,
});
const config = mockRequest.mock.calls[0][0];
const [, config] = mockFetch.mock.calls[0];
expect(config.headers["content-type"]).toBe(
"multipart/form-data; boundary=abc"
);
@@ -128,30 +182,27 @@ describe("HttpClient", () => {
});
it("maps 401 and 429 errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: {
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
ok: false,
status: 401,
data: { message: "unauthorized" },
headers: {},
},
});
})
);
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(AuthenticationError);
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: {
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
ok: false,
status: 429,
data: { message: "rate" },
headers: { "retry-after": "2" },
},
});
data: { message: "rate" },
})
);
const error = await client
.requestRaw({ method: "GET", path: "/meta" })
.catch((err) => err);
@@ -160,109 +211,96 @@ describe("HttpClient", () => {
});
it("maps validation and upload errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: {
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
ok: false,
status: 422,
data: { message: "invalid" },
headers: {},
},
});
})
);
await expect(
client.requestRaw({ method: "POST", path: "/chat-messages", data: { user: "u" } })
).rejects.toBeInstanceOf(ValidationError);
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
config: { url: "/files/upload" },
response: {
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
ok: false,
status: 400,
data: { message: "bad upload" },
headers: {},
},
});
})
);
await expect(
client.requestRaw({ method: "POST", path: "/files/upload", data: { user: "u" } })
).rejects.toBeInstanceOf(FileUploadError);
});
it("maps timeout and network errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
code: "ECONNABORTED",
message: "timeout",
});
// Test AbortError (which is what timeout produces)
global.fetch = vi.fn().mockRejectedValue(new DOMException("aborted", "AbortError"));
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(TimeoutError);
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
message: "network",
});
// Test network error
global.fetch = vi.fn().mockRejectedValue(new Error("network"));
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(NetworkError);
});
it("retries on timeout errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn()
.mockRejectedValueOnce(new DOMException("aborted", "AbortError"))
.mockResolvedValueOnce(
createMockResponse({
status: 200,
data: "ok",
})
);
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test", maxRetries: 1, retryDelay: 0 });
mockRequest
.mockRejectedValueOnce({
isAxiosError: true,
code: "ECONNABORTED",
message: "timeout",
})
.mockResolvedValueOnce({ status: 200, data: "ok", headers: {} });
await client.requestRaw({ method: "GET", path: "/meta" });
expect(mockRequest).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("validates query parameters before request", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn();
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" });
await expect(
client.requestRaw({ method: "GET", path: "/meta", query: { user: 1 } })
).rejects.toBeInstanceOf(ValidationError);
expect(mockRequest).not.toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
});
it("returns APIError for other http failures", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
ok: false,
status: 500,
data: { message: "server" },
})
);
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: { status: 500, data: { message: "server" }, headers: {} },
});
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(APIError);
});
it("logs requests and responses when enableLogging is true", async () => {
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: { ok: true },
headers: {},
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
status: 200,
data: { ok: true },
})
);
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const client = new HttpClient({ apiKey: "test", enableLogging: true });
@@ -275,8 +313,15 @@ describe("HttpClient", () => {
});
it("logs retry attempts when enableLogging is true", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn()
.mockRejectedValueOnce(new DOMException("aborted", "AbortError"))
.mockResolvedValueOnce(
createMockResponse({
status: 200,
data: "ok",
})
);
global.fetch = mockFetch;
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const client = new HttpClient({
@@ -286,14 +331,6 @@ describe("HttpClient", () => {
enableLogging: true,
});
mockRequest
.mockRejectedValueOnce({
isAxiosError: true,
code: "ECONNABORTED",
message: "timeout",
})
.mockResolvedValueOnce({ status: 200, data: "ok", headers: {} });
await client.requestRaw({ method: "GET", path: "/meta" });
expect(consoleInfo).toHaveBeenCalledWith(
+174 -88
View File
@@ -1,10 +1,3 @@
import axios from "axios";
import type {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
} from "axios";
import type { Readable } from "node:stream";
import {
DEFAULT_BASE_URL,
@@ -19,8 +12,8 @@ import type {
QueryParams,
RequestMethod,
} from "../types/common";
import type { DifyError } from "../errors/dify-error";
import {
DifyError,
APIError,
AuthenticationError,
FileUploadError,
@@ -36,13 +29,15 @@ import { validateParams } from "../client/validation";
const DEFAULT_USER_AGENT = "dify-client-node";
export type ResponseType = "json" | "stream" | "text" | "blob" | "arraybuffer";
export type RequestOptions = {
method: RequestMethod;
path: string;
query?: QueryParams;
data?: unknown;
headers?: Headers;
responseType?: AxiosRequestConfig["responseType"];
responseType?: ResponseType;
};
export type HttpClientSettings = Required<
@@ -60,12 +55,15 @@ const normalizeSettings = (config: DifyClientConfig): HttpClientSettings => ({
enableLogging: config.enableLogging ?? false,
});
const normalizeHeaders = (headers: AxiosResponse["headers"]): Headers => {
const normalizeHeaders = (headers: Record<string, string | string[] | number | undefined>): Headers => {
const result: Headers = {};
if (!headers) {
return result;
}
Object.entries(headers).forEach(([key, value]) => {
if (value === undefined) {
return;
}
if (Array.isArray(value)) {
result[key.toLowerCase()] = value.join(", ");
} else if (typeof value === "string") {
@@ -121,6 +119,28 @@ const parseRetryAfterSeconds = (headerValue?: string): number | undefined => {
return undefined;
};
const shouldParseJson = (contentType: string | null, text: string): boolean => {
if (contentType) {
return contentType.toLowerCase().includes("application/json");
}
return text.length > 0;
};
const parseResponseBody = (text: string, contentType: string | null): unknown => {
if (!shouldParseJson(contentType, text)) {
return text;
}
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
// Fallback to raw text if JSON parsing fails
return text;
}
};
const isReadableStream = (value: unknown): value is Readable => {
if (!value || typeof value !== "object") {
return false;
@@ -128,17 +148,34 @@ const isReadableStream = (value: unknown): value is Readable => {
return typeof (value as { pipe?: unknown }).pipe === "function";
};
const isUploadLikeRequest = (config?: AxiosRequestConfig): boolean => {
const url = (config?.url ?? "").toLowerCase();
const createBodyFactory = (
method: RequestMethod,
data?: unknown
): { getBody: () => BodyInit | undefined; canRetry: boolean } => {
if (method === "GET" || data === undefined) {
return { getBody: () => undefined, canRetry: true };
}
if (isReadableStream(data)) {
return { getBody: () => data as unknown as BodyInit, canRetry: false };
}
if (isFormData(data)) {
return { getBody: () => data as BodyInit, canRetry: true };
}
const jsonBody = JSON.stringify(data);
return { getBody: () => jsonBody, canRetry: true };
};
const isUploadLikeRequest = (url: string): boolean => {
if (!url) {
return false;
}
const lowerUrl = url.toLowerCase();
return (
url.includes("upload") ||
url.includes("/files/") ||
url.includes("audio-to-text") ||
url.includes("create_by_file") ||
url.includes("update_by_file")
lowerUrl.includes("upload") ||
lowerUrl.includes("/files/") ||
lowerUrl.includes("audio-to-text") ||
lowerUrl.includes("create_by_file") ||
lowerUrl.includes("update_by_file")
);
};
@@ -159,75 +196,71 @@ const resolveErrorMessage = (status: number, responseBody: unknown): string => {
return `Request failed with status code ${status}`;
};
const mapAxiosError = (error: unknown): DifyError => {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.response) {
const status = axiosError.response.status;
const headers = normalizeHeaders(axiosError.response.headers);
const requestId = resolveRequestId(headers);
const responseBody = axiosError.response.data;
const message = resolveErrorMessage(status, responseBody);
const mapFetchError = (
error: unknown,
url: string,
response?: Response,
responseBody?: unknown
): DifyError => {
if (response) {
const status = response.status;
const headers = normalizeHeaders(Object.fromEntries(response.headers.entries()));
const requestId = resolveRequestId(headers);
const message = resolveErrorMessage(status, responseBody);
if (status === 401) {
return new AuthenticationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (status === 429) {
const retryAfter = parseRetryAfterSeconds(headers["retry-after"]);
return new RateLimitError(message, {
statusCode: status,
responseBody,
requestId,
retryAfter,
});
}
if (status === 422) {
return new ValidationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (status === 400) {
if (isUploadLikeRequest(axiosError.config)) {
return new FileUploadError(message, {
statusCode: status,
responseBody,
requestId,
});
}
}
return new APIError(message, {
if (status === 401) {
return new AuthenticationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (axiosError.code === "ECONNABORTED") {
return new TimeoutError("Request timed out", { cause: axiosError });
if (status === 429) {
const retryAfter = parseRetryAfterSeconds(headers["retry-after"]);
return new RateLimitError(message, {
statusCode: status,
responseBody,
requestId,
retryAfter,
});
}
return new NetworkError(axiosError.message, { cause: axiosError });
if (status === 422) {
return new ValidationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (status === 400) {
if (isUploadLikeRequest(url)) {
return new FileUploadError(message, {
statusCode: status,
responseBody,
requestId,
});
}
}
return new APIError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (error instanceof Error) {
if (error.name === "AbortError" || error.message.includes("aborted")) {
return new TimeoutError("Request timed out", { cause: error });
}
return new NetworkError(error.message, { cause: error });
}
return new NetworkError("Unexpected network error", { cause: error });
};
export class HttpClient {
private axios: AxiosInstance;
private settings: HttpClientSettings;
constructor(config: DifyClientConfig) {
this.settings = normalizeSettings(config);
this.axios = axios.create({
baseURL: this.settings.baseUrl,
timeout: this.settings.timeout * 1000,
});
}
updateApiKey(apiKey: string): void {
@@ -275,7 +308,11 @@ export class HttpClient {
});
}
async requestRaw(options: RequestOptions): Promise<AxiosResponse> {
async requestRaw(options: RequestOptions): Promise<{
status: number;
data: unknown;
headers: Headers;
}> {
const { method, path, query, data, headers, responseType } = options;
const { apiKey, enableLogging, maxRetries, retryDelay, timeout } =
this.settings;
@@ -312,44 +349,93 @@ export class HttpClient {
requestHeaders["Content-Type"] = "application/json";
}
const url = buildRequestUrl(this.settings.baseUrl, path);
let url = buildRequestUrl(this.settings.baseUrl, path);
const queryString = buildQueryString(query);
if (queryString) {
url += `?${queryString}`;
}
if (enableLogging) {
console.info(`dify-client-node request ${method} ${url}`);
}
const axiosConfig: AxiosRequestConfig = {
method,
url: path,
params: query,
paramsSerializer: {
serialize: (params) => buildQueryString(params as QueryParams),
},
headers: requestHeaders,
responseType: responseType ?? "json",
timeout: timeout * 1000,
};
if (method !== "GET" && data !== undefined) {
axiosConfig.data = data;
}
const { getBody, canRetry: canRetryBody } = createBodyFactory(method, data);
let attempt = 0;
// `attempt` is a zero-based retry counter
// Total attempts = 1 (initial) + maxRetries
// e.g., maxRetries=3 means: attempt 0 (initial), then retries at 1, 2, 3
while (true) {
// Create a new AbortController for each attempt to handle timeouts properly
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), timeout * 1000);
try {
const response = await this.axios.request(axiosConfig);
const response = await fetch(url, {
method,
headers: requestHeaders,
body: getBody(),
signal: abortController.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const contentType = response.headers.get("content-type");
// Read body as text first to avoid "Body has already been read" error
const text = await response.text();
const responseBody = parseResponseBody(text, contentType);
throw mapFetchError(new Error(`HTTP ${response.status}`), url, response, responseBody);
}
if (enableLogging) {
console.info(
`dify-client-node response ${response.status} ${method} ${url}`
);
}
return response;
let responseData: unknown;
if (responseType === "stream") {
// For Node.js, we need to convert web streams to Node.js streams
if (response.body) {
const { Readable } = await import("node:stream");
// Type assertion needed: DOM ReadableStream vs Node.js stream types are incompatible
// but Readable.fromWeb handles the conversion correctly at runtime
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
responseData = Readable.fromWeb(response.body as any);
} else {
throw new Error("Response body is null");
}
} else if (responseType === "text") {
responseData = await response.text();
} else if (responseType === "blob") {
responseData = await response.blob();
} else if (responseType === "arraybuffer") {
responseData = await response.arrayBuffer();
} else {
// json or default
const contentType = response.headers.get("content-type");
// Read body as text first to handle malformed JSON gracefully
const text = await response.text();
responseData = parseResponseBody(text, contentType);
}
return {
status: response.status,
data: responseData,
headers: Object.fromEntries(response.headers.entries()),
};
} catch (error) {
const mapped = mapAxiosError(error);
if (!shouldRetry(mapped, attempt, maxRetries)) {
clearTimeout(timeoutId);
let mapped: DifyError;
if (error instanceof DifyError) {
mapped = error;
} else {
mapped = mapFetchError(error, url);
}
if (!canRetryBody || !shouldRetry(mapped, attempt, maxRetries)) {
throw mapped;
}
const retryAfterSeconds =
+125 -113
View File
@@ -1,27 +1,44 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatClient, DifyClient, WorkflowClient, BASE_URL, routes } from "./index";
import axios from "axios";
const mockRequest = vi.fn();
const mockFetch = vi.fn();
const setupAxiosMock = () => {
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
// Helper to create a mock fetch response
const createMockResponse = (options = {}) => {
const {
ok = true,
status = 200,
headers = {},
body = null,
data = null,
} = options;
const headersObj = new Headers(headers);
const response = {
ok,
status,
headers: headersObj,
body,
json: vi.fn().mockResolvedValue(data),
text: vi.fn().mockResolvedValue(typeof data === 'string' ? data : JSON.stringify(data)),
blob: vi.fn().mockResolvedValue(new Blob()),
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)),
};
return response;
};
beforeEach(() => {
vi.restoreAllMocks();
mockRequest.mockReset();
setupAxiosMock();
mockFetch.mockReset();
global.fetch = mockFetch;
});
describe("Client", () => {
it("should create a client", () => {
new DifyClient("test");
expect(axios.create).toHaveBeenCalledWith({
baseURL: BASE_URL,
timeout: 60000,
});
// Just verify client can be created successfully
expect(true).toBe(true);
});
it("should update the api key", () => {
@@ -37,41 +54,37 @@ describe("Send Requests", () => {
const difyClient = new DifyClient("test");
const method = "GET";
const endpoint = routes.application.url();
mockRequest.mockResolvedValue({
status: 200,
data: "response",
headers: {},
});
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "response",
})
);
await difyClient.sendRequest(method, endpoint);
const requestConfig = mockRequest.mock.calls[0][0];
expect(requestConfig).toMatchObject({
method,
url: endpoint,
params: undefined,
responseType: "json",
timeout: 60000,
});
expect(requestConfig.headers.Authorization).toBe("Bearer test");
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(endpoint);
expect(config.method).toBe(method);
expect(config.headers.Authorization).toBe("Bearer test");
});
it("uses the getMeta route configuration", async () => {
const difyClient = new DifyClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await difyClient.getMeta("end-user");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.getMeta.method,
url: routes.getMeta.url(),
params: { user: "end-user" },
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(routes.getMeta.url());
expect(url).toContain("user=end-user");
expect(config.method).toBe(routes.getMeta.method);
expect(config.headers.Authorization).toBe("Bearer test");
});
});
@@ -97,49 +110,52 @@ describe("File uploads", () => {
it("does not override multipart boundary headers for FormData", async () => {
const difyClient = new DifyClient("test");
const form = new globalThis.FormData();
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await difyClient.fileUpload(form, "end-user");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.fileUpload.method,
url: routes.fileUpload.url(),
params: undefined,
headers: expect.objectContaining({
Authorization: "Bearer test",
"content-type": "multipart/form-data; boundary=test",
}),
responseType: "json",
timeout: 60000,
data: form,
}));
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(routes.fileUpload.url());
expect(config.method).toBe(routes.fileUpload.method);
expect(config.headers.Authorization).toBe("Bearer test");
expect(config.headers["content-type"]).toBe("multipart/form-data; boundary=test");
expect(config.body).toBe(form);
});
});
describe("Workflow client", () => {
it("uses tasks stop path for workflow stop", async () => {
const workflowClient = new WorkflowClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "stopped", headers: {} });
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "stopped",
})
);
await workflowClient.stop("task-1", "end-user");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.stopWorkflow.method,
url: routes.stopWorkflow.url("task-1"),
params: undefined,
headers: expect.objectContaining({
Authorization: "Bearer test",
"Content-Type": "application/json",
}),
responseType: "json",
timeout: 60000,
data: { user: "end-user" },
}));
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(routes.stopWorkflow.url("task-1"));
expect(config.method).toBe(routes.stopWorkflow.method);
expect(config.headers.Authorization).toBe("Bearer test");
expect(config.headers["Content-Type"]).toBe("application/json");
expect(JSON.parse(config.body)).toEqual({ user: "end-user" });
});
it("maps workflow log filters to service api params", async () => {
const workflowClient = new WorkflowClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await workflowClient.getLogs({
createdAtAfter: "2024-01-01T00:00:00Z",
@@ -150,78 +166,74 @@ describe("Workflow client", () => {
limit: 10,
});
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: "GET",
url: "/workflows/logs",
params: {
created_at__after: "2024-01-01T00:00:00Z",
created_at__before: "2024-01-02T00:00:00Z",
created_by_end_user_session_id: "sess-1",
created_by_account: "acc-1",
page: 2,
limit: 10,
},
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain("/workflows/logs");
expect(url).toContain("created_at__after=2024-01-01T00%3A00%3A00Z");
expect(url).toContain("created_at__before=2024-01-02T00%3A00%3A00Z");
expect(url).toContain("created_by_end_user_session_id=sess-1");
expect(url).toContain("created_by_account=acc-1");
expect(url).toContain("page=2");
expect(url).toContain("limit=10");
expect(config.method).toBe("GET");
expect(config.headers.Authorization).toBe("Bearer test");
});
});
describe("Chat client", () => {
it("places user in query for suggested messages", async () => {
const chatClient = new ChatClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await chatClient.getSuggested("msg-1", "end-user");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.getSuggested.method,
url: routes.getSuggested.url("msg-1"),
params: { user: "end-user" },
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(routes.getSuggested.url("msg-1"));
expect(url).toContain("user=end-user");
expect(config.method).toBe(routes.getSuggested.method);
expect(config.headers.Authorization).toBe("Bearer test");
});
it("uses last_id when listing conversations", async () => {
const chatClient = new ChatClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await chatClient.getConversations("end-user", "last-1", 10);
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.getConversations.method,
url: routes.getConversations.url(),
params: { user: "end-user", last_id: "last-1", limit: 10 },
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(routes.getConversations.url());
expect(url).toContain("user=end-user");
expect(url).toContain("last_id=last-1");
expect(url).toContain("limit=10");
expect(config.method).toBe(routes.getConversations.method);
expect(config.headers.Authorization).toBe("Bearer test");
});
it("lists app feedbacks without user params", async () => {
const chatClient = new ChatClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await chatClient.getAppFeedbacks(1, 20);
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: "GET",
url: "/app/feedbacks",
params: { page: 1, limit: 20 },
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain("/app/feedbacks");
expect(url).toContain("page=1");
expect(url).toContain("limit=20");
expect(config.method).toBe("GET");
expect(config.headers.Authorization).toBe("Bearer test");
});
});
+5 -6
View File
@@ -1,16 +1,15 @@
import axios from "axios";
import { vi } from "vitest";
import { HttpClient } from "../src/http/client";
export const createHttpClient = (configOverrides = {}) => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn();
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test", ...configOverrides });
return { client, mockRequest };
return { client, mockFetch };
};
export const createHttpClientWithSpies = (configOverrides = {}) => {
const { client, mockRequest } = createHttpClient(configOverrides);
const { client, mockFetch } = createHttpClient(configOverrides);
const request = vi
.spyOn(client, "request")
.mockResolvedValue({ data: "ok", status: 200, headers: {} });
@@ -22,7 +21,7 @@ export const createHttpClientWithSpies = (configOverrides = {}) => {
.mockResolvedValue({ data: null });
return {
client,
mockRequest,
mockFetch,
request,
requestStream,
requestBinaryStream,
@@ -1,10 +1,13 @@
'use client'
import type { PluginDetail } from '../types'
import type { FilterState } from './filter-management'
import { useDebounceFn } from 'ahooks'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
import { useGetLanguage } from '@/context/i18n'
import { renderI18nObject } from '@/i18n-config'
import { useInstalledLatestVersion, useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import Loading from '../../base/loading'
import { PluginSource } from '../types'
@@ -13,8 +16,34 @@ import Empty from './empty'
import FilterManagement from './filter-management'
import List from './list'
const matchesSearchQuery = (plugin: PluginDetail & { latest_version: string }, query: string, locale: string): boolean => {
if (!query)
return true
const lowerQuery = query.toLowerCase()
const { declaration } = plugin
// Match plugin_id
if (plugin.plugin_id.toLowerCase().includes(lowerQuery))
return true
// Match plugin name
if (plugin.name?.toLowerCase().includes(lowerQuery))
return true
// Match declaration name
if (declaration.name?.toLowerCase().includes(lowerQuery))
return true
// Match localized label
const label = renderI18nObject(declaration.label, locale)
if (label?.toLowerCase().includes(lowerQuery))
return true
// Match localized description
const description = renderI18nObject(declaration.description, locale)
if (description?.toLowerCase().includes(lowerQuery))
return true
return false
}
const PluginsPanel = () => {
const { t } = useTranslation()
const locale = useGetLanguage()
const filters = usePluginPageContext(v => v.filters) as FilterState
const setFilters = usePluginPageContext(v => v.setFilters)
const { data: pluginList, isLoading: isPluginListLoading, isFetching, isLastPage, loadNextPage } = useInstalledPluginList()
@@ -48,11 +77,11 @@ const PluginsPanel = () => {
return (
(categories.length === 0 || categories.includes(plugin.declaration.category))
&& (tags.length === 0 || tags.some(tag => plugin.declaration.tags.includes(tag)))
&& (searchQuery === '' || plugin.plugin_id.toLowerCase().includes(searchQuery.toLowerCase()))
&& matchesSearchQuery(plugin, searchQuery, locale)
)
})
return filteredList
}, [pluginListWithLatestVersion, filters])
}, [pluginListWithLatestVersion, filters, locale])
const currentPluginDetail = useMemo(() => {
const detail = pluginListWithLatestVersion.find(plugin => plugin.plugin_id === currentPluginID)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,971 @@
import type { PanelProps } from '@/app/components/workflow/panel'
import { render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import RagPipelinePanel from './index'
// ============================================================================
// Mock External Dependencies
// ============================================================================
// Type definitions for dynamic module
type DynamicModule = {
default?: React.ComponentType<Record<string, unknown>>
}
type PromiseOrModule = Promise<DynamicModule> | DynamicModule
// Mock next/dynamic to return synchronous components immediately
vi.mock('next/dynamic', () => ({
default: (loader: () => PromiseOrModule, _options?: Record<string, unknown>) => {
let Component: React.ComponentType<Record<string, unknown>> | null = null
// Try to resolve the loader synchronously for mocked modules
try {
const result = loader() as PromiseOrModule
if (result && typeof (result as Promise<DynamicModule>).then === 'function') {
// For async modules, we need to handle them specially
// This will work with vi.mock since mocks resolve synchronously
(result as Promise<DynamicModule>).then((mod: DynamicModule) => {
Component = (mod.default || mod) as React.ComponentType<Record<string, unknown>>
})
}
else if (result) {
Component = ((result as DynamicModule).default || result) as React.ComponentType<Record<string, unknown>>
}
}
catch {
// If the module can't be resolved, Component stays null
}
// Return a simple wrapper that renders the component or null
const DynamicComponent = React.forwardRef((props: Record<string, unknown>, ref: React.Ref<unknown>) => {
// For mocked modules, Component should already be set
if (Component)
return <Component {...props} ref={ref} />
return null
})
DynamicComponent.displayName = 'DynamicComponent'
return DynamicComponent
},
}))
// Mock workflow store
let mockHistoryWorkflowData: Record<string, unknown> | null = null
let mockShowDebugAndPreviewPanel = false
let mockShowGlobalVariablePanel = false
let mockShowInputFieldPanel = false
let mockShowInputFieldPreviewPanel = false
let mockInputFieldEditPanelProps: Record<string, unknown> | null = null
let mockPipelineId = 'test-pipeline-123'
type MockStoreState = {
historyWorkflowData: Record<string, unknown> | null
showDebugAndPreviewPanel: boolean
showGlobalVariablePanel: boolean
showInputFieldPanel: boolean
showInputFieldPreviewPanel: boolean
inputFieldEditPanelProps: Record<string, unknown> | null
pipelineId: string
}
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: MockStoreState) => unknown) => {
const state: MockStoreState = {
historyWorkflowData: mockHistoryWorkflowData,
showDebugAndPreviewPanel: mockShowDebugAndPreviewPanel,
showGlobalVariablePanel: mockShowGlobalVariablePanel,
showInputFieldPanel: mockShowInputFieldPanel,
showInputFieldPreviewPanel: mockShowInputFieldPreviewPanel,
inputFieldEditPanelProps: mockInputFieldEditPanelProps,
pipelineId: mockPipelineId,
}
return selector(state)
},
}))
// Mock Panel component to capture props and render children
let capturedPanelProps: PanelProps | null = null
vi.mock('@/app/components/workflow/panel', () => ({
default: (props: PanelProps) => {
capturedPanelProps = props
return (
<div data-testid="workflow-panel">
<div data-testid="panel-left">{props.components?.left}</div>
<div data-testid="panel-right">{props.components?.right}</div>
</div>
)
},
}))
// Mock Record component
vi.mock('@/app/components/workflow/panel/record', () => ({
default: () => <div data-testid="record-panel">Record Panel</div>,
}))
// Mock TestRunPanel component
vi.mock('@/app/components/rag-pipeline/components/panel/test-run', () => ({
default: () => <div data-testid="test-run-panel">Test Run Panel</div>,
}))
// Mock InputFieldPanel component
vi.mock('./input-field', () => ({
default: () => <div data-testid="input-field-panel">Input Field Panel</div>,
}))
// Mock InputFieldEditorPanel component
const mockInputFieldEditorProps = vi.fn()
vi.mock('./input-field/editor', () => ({
default: (props: Record<string, unknown>) => {
mockInputFieldEditorProps(props)
return <div data-testid="input-field-editor-panel">Input Field Editor Panel</div>
},
}))
// Mock PreviewPanel component
vi.mock('./input-field/preview', () => ({
default: () => <div data-testid="preview-panel">Preview Panel</div>,
}))
// Mock GlobalVariablePanel component
vi.mock('@/app/components/workflow/panel/global-variable-panel', () => ({
default: () => <div data-testid="global-variable-panel">Global Variable Panel</div>,
}))
// ============================================================================
// Helper Functions
// ============================================================================
type SetupMockOptions = {
historyWorkflowData?: Record<string, unknown> | null
showDebugAndPreviewPanel?: boolean
showGlobalVariablePanel?: boolean
showInputFieldPanel?: boolean
showInputFieldPreviewPanel?: boolean
inputFieldEditPanelProps?: Record<string, unknown> | null
pipelineId?: string
}
const setupMocks = (options?: SetupMockOptions) => {
mockHistoryWorkflowData = options?.historyWorkflowData ?? null
mockShowDebugAndPreviewPanel = options?.showDebugAndPreviewPanel ?? false
mockShowGlobalVariablePanel = options?.showGlobalVariablePanel ?? false
mockShowInputFieldPanel = options?.showInputFieldPanel ?? false
mockShowInputFieldPreviewPanel = options?.showInputFieldPreviewPanel ?? false
mockInputFieldEditPanelProps = options?.inputFieldEditPanelProps ?? null
mockPipelineId = options?.pipelineId ?? 'test-pipeline-123'
capturedPanelProps = null
}
// ============================================================================
// RagPipelinePanel Component Tests
// ============================================================================
describe('RagPipelinePanel', () => {
beforeEach(() => {
vi.clearAllMocks()
setupMocks()
})
// -------------------------------------------------------------------------
// Rendering Tests
// -------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', async () => {
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('workflow-panel')).toBeInTheDocument()
})
})
it('should render Panel component with correct structure', async () => {
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('panel-left')).toBeInTheDocument()
expect(screen.getByTestId('panel-right')).toBeInTheDocument()
})
})
it('should pass versionHistoryPanelProps to Panel', async () => {
// Arrange
setupMocks({ pipelineId: 'my-pipeline-456' })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps).toBeDefined()
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe(
'/rag/pipelines/my-pipeline-456/workflows',
)
})
})
})
// -------------------------------------------------------------------------
// Memoization Tests - versionHistoryPanelProps
// -------------------------------------------------------------------------
describe('Memoization - versionHistoryPanelProps', () => {
it('should compute correct getVersionListUrl based on pipelineId', async () => {
// Arrange
setupMocks({ pipelineId: 'pipeline-abc' })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe(
'/rag/pipelines/pipeline-abc/workflows',
)
})
})
it('should compute correct deleteVersionUrl function', async () => {
// Arrange
setupMocks({ pipelineId: 'pipeline-xyz' })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
const deleteUrl = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1')
expect(deleteUrl).toBe('/rag/pipelines/pipeline-xyz/workflows/version-1')
})
})
it('should compute correct updateVersionUrl function', async () => {
// Arrange
setupMocks({ pipelineId: 'pipeline-def' })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
const updateUrl = capturedPanelProps?.versionHistoryPanelProps?.updateVersionUrl?.('version-2')
expect(updateUrl).toBe('/rag/pipelines/pipeline-def/workflows/version-2')
})
})
it('should set latestVersionId to empty string', async () => {
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps?.latestVersionId).toBe('')
})
})
})
// -------------------------------------------------------------------------
// Memoization Tests - panelProps
// -------------------------------------------------------------------------
describe('Memoization - panelProps', () => {
it('should pass components.left to Panel', async () => {
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.components?.left).toBeDefined()
})
})
it('should pass components.right to Panel', async () => {
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.components?.right).toBeDefined()
})
})
it('should pass versionHistoryPanelProps to panelProps', async () => {
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps).toBeDefined()
})
})
})
// -------------------------------------------------------------------------
// Component Memoization Tests (React.memo)
// -------------------------------------------------------------------------
describe('Component Memoization', () => {
it('should be wrapped with React.memo', async () => {
// The component should not break when re-rendered
const { rerender } = render(<RagPipelinePanel />)
// Act - rerender without prop changes
rerender(<RagPipelinePanel />)
// Assert - component should still render correctly
await waitFor(() => {
expect(screen.getByTestId('workflow-panel')).toBeInTheDocument()
})
})
})
})
// ============================================================================
// RagPipelinePanelOnRight Component Tests
// ============================================================================
describe('RagPipelinePanelOnRight', () => {
beforeEach(() => {
vi.clearAllMocks()
setupMocks()
})
// -------------------------------------------------------------------------
// Conditional Rendering - Record Panel
// -------------------------------------------------------------------------
describe('Record Panel Conditional Rendering', () => {
it('should render Record panel when historyWorkflowData exists', async () => {
// Arrange
setupMocks({ historyWorkflowData: { id: 'history-1' } })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('record-panel')).toBeInTheDocument()
})
})
it('should not render Record panel when historyWorkflowData is null', async () => {
// Arrange
setupMocks({ historyWorkflowData: null })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument()
})
})
it('should not render Record panel when historyWorkflowData is undefined', async () => {
// Arrange
setupMocks({ historyWorkflowData: undefined })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument()
})
})
})
// -------------------------------------------------------------------------
// Conditional Rendering - TestRun Panel
// -------------------------------------------------------------------------
describe('TestRun Panel Conditional Rendering', () => {
it('should render TestRun panel when showDebugAndPreviewPanel is true', async () => {
// Arrange
setupMocks({ showDebugAndPreviewPanel: true })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('test-run-panel')).toBeInTheDocument()
})
})
it('should not render TestRun panel when showDebugAndPreviewPanel is false', async () => {
// Arrange
setupMocks({ showDebugAndPreviewPanel: false })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('test-run-panel')).not.toBeInTheDocument()
})
})
})
// -------------------------------------------------------------------------
// Conditional Rendering - GlobalVariable Panel
// -------------------------------------------------------------------------
describe('GlobalVariable Panel Conditional Rendering', () => {
it('should render GlobalVariable panel when showGlobalVariablePanel is true', async () => {
// Arrange
setupMocks({ showGlobalVariablePanel: true })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument()
})
})
it('should not render GlobalVariable panel when showGlobalVariablePanel is false', async () => {
// Arrange
setupMocks({ showGlobalVariablePanel: false })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('global-variable-panel')).not.toBeInTheDocument()
})
})
})
// -------------------------------------------------------------------------
// Multiple Panels Rendering
// -------------------------------------------------------------------------
describe('Multiple Panels Rendering', () => {
it('should render all right panels when all conditions are true', async () => {
// Arrange
setupMocks({
historyWorkflowData: { id: 'history-1' },
showDebugAndPreviewPanel: true,
showGlobalVariablePanel: true,
})
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('record-panel')).toBeInTheDocument()
expect(screen.getByTestId('test-run-panel')).toBeInTheDocument()
expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument()
})
})
it('should render no right panels when all conditions are false', async () => {
// Arrange
setupMocks({
historyWorkflowData: null,
showDebugAndPreviewPanel: false,
showGlobalVariablePanel: false,
})
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('test-run-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('global-variable-panel')).not.toBeInTheDocument()
})
})
it('should render only Record and TestRun panels', async () => {
// Arrange
setupMocks({
historyWorkflowData: { id: 'history-1' },
showDebugAndPreviewPanel: true,
showGlobalVariablePanel: false,
})
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('record-panel')).toBeInTheDocument()
expect(screen.getByTestId('test-run-panel')).toBeInTheDocument()
expect(screen.queryByTestId('global-variable-panel')).not.toBeInTheDocument()
})
})
})
})
// ============================================================================
// RagPipelinePanelOnLeft Component Tests
// ============================================================================
describe('RagPipelinePanelOnLeft', () => {
beforeEach(() => {
vi.clearAllMocks()
setupMocks()
})
// -------------------------------------------------------------------------
// Conditional Rendering - Preview Panel
// -------------------------------------------------------------------------
describe('Preview Panel Conditional Rendering', () => {
it('should render Preview panel when showInputFieldPreviewPanel is true', async () => {
// Arrange
setupMocks({ showInputFieldPreviewPanel: true })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('preview-panel')).toBeInTheDocument()
})
})
it('should not render Preview panel when showInputFieldPreviewPanel is false', async () => {
// Arrange
setupMocks({ showInputFieldPreviewPanel: false })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('preview-panel')).not.toBeInTheDocument()
})
})
})
// -------------------------------------------------------------------------
// Conditional Rendering - InputFieldEditor Panel
// -------------------------------------------------------------------------
describe('InputFieldEditor Panel Conditional Rendering', () => {
it('should render InputFieldEditor panel when inputFieldEditPanelProps is provided', async () => {
// Arrange
const editProps = {
onClose: vi.fn(),
onSubmit: vi.fn(),
initialData: { variable: 'test' },
}
setupMocks({ inputFieldEditPanelProps: editProps })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('input-field-editor-panel')).toBeInTheDocument()
})
})
it('should not render InputFieldEditor panel when inputFieldEditPanelProps is null', async () => {
// Arrange
setupMocks({ inputFieldEditPanelProps: null })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('input-field-editor-panel')).not.toBeInTheDocument()
})
})
it('should pass props to InputFieldEditor panel', async () => {
// Arrange
const editProps = {
onClose: vi.fn(),
onSubmit: vi.fn(),
initialData: { variable: 'test_var', label: 'Test Label' },
}
setupMocks({ inputFieldEditPanelProps: editProps })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(mockInputFieldEditorProps).toHaveBeenCalledWith(
expect.objectContaining({
onClose: editProps.onClose,
onSubmit: editProps.onSubmit,
initialData: editProps.initialData,
}),
)
})
})
})
// -------------------------------------------------------------------------
// Conditional Rendering - InputField Panel
// -------------------------------------------------------------------------
describe('InputField Panel Conditional Rendering', () => {
it('should render InputField panel when showInputFieldPanel is true', async () => {
// Arrange
setupMocks({ showInputFieldPanel: true })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('input-field-panel')).toBeInTheDocument()
})
})
it('should not render InputField panel when showInputFieldPanel is false', async () => {
// Arrange
setupMocks({ showInputFieldPanel: false })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('input-field-panel')).not.toBeInTheDocument()
})
})
})
// -------------------------------------------------------------------------
// Multiple Panels Rendering
// -------------------------------------------------------------------------
describe('Multiple Left Panels Rendering', () => {
it('should render all left panels when all conditions are true', async () => {
// Arrange
setupMocks({
showInputFieldPreviewPanel: true,
inputFieldEditPanelProps: { onClose: vi.fn(), onSubmit: vi.fn() },
showInputFieldPanel: true,
})
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('preview-panel')).toBeInTheDocument()
expect(screen.getByTestId('input-field-editor-panel')).toBeInTheDocument()
expect(screen.getByTestId('input-field-panel')).toBeInTheDocument()
})
})
it('should render no left panels when all conditions are false', async () => {
// Arrange
setupMocks({
showInputFieldPreviewPanel: false,
inputFieldEditPanelProps: null,
showInputFieldPanel: false,
})
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('preview-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('input-field-editor-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('input-field-panel')).not.toBeInTheDocument()
})
})
it('should render only Preview and InputField panels', async () => {
// Arrange
setupMocks({
showInputFieldPreviewPanel: true,
inputFieldEditPanelProps: null,
showInputFieldPanel: true,
})
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(screen.getByTestId('preview-panel')).toBeInTheDocument()
expect(screen.queryByTestId('input-field-editor-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('input-field-panel')).toBeInTheDocument()
})
})
})
})
// ============================================================================
// Edge Cases Tests
// ============================================================================
describe('Edge Cases', () => {
beforeEach(() => {
vi.clearAllMocks()
setupMocks()
})
// -------------------------------------------------------------------------
// Empty/Undefined Values
// -------------------------------------------------------------------------
describe('Empty/Undefined Values', () => {
it('should handle empty pipelineId gracefully', async () => {
// Arrange
setupMocks({ pipelineId: '' })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe(
'/rag/pipelines//workflows',
)
})
})
it('should handle special characters in pipelineId', async () => {
// Arrange
setupMocks({ pipelineId: 'pipeline-with-special_chars.123' })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe(
'/rag/pipelines/pipeline-with-special_chars.123/workflows',
)
})
})
})
// -------------------------------------------------------------------------
// Props Spreading Tests
// -------------------------------------------------------------------------
describe('Props Spreading', () => {
it('should correctly spread inputFieldEditPanelProps to editor component', async () => {
// Arrange
const customProps = {
onClose: vi.fn(),
onSubmit: vi.fn(),
initialData: {
variable: 'custom_var',
label: 'Custom Label',
type: 'text',
},
extraProp: 'extra-value',
}
setupMocks({ inputFieldEditPanelProps: customProps })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(mockInputFieldEditorProps).toHaveBeenCalledWith(
expect.objectContaining({
extraProp: 'extra-value',
}),
)
})
})
})
// -------------------------------------------------------------------------
// State Combinations
// -------------------------------------------------------------------------
describe('State Combinations', () => {
it('should handle all panels visible simultaneously', async () => {
// Arrange
setupMocks({
historyWorkflowData: { id: 'h1' },
showDebugAndPreviewPanel: true,
showGlobalVariablePanel: true,
showInputFieldPreviewPanel: true,
inputFieldEditPanelProps: { onClose: vi.fn(), onSubmit: vi.fn() },
showInputFieldPanel: true,
})
// Act
render(<RagPipelinePanel />)
// Assert - All panels should be visible
await waitFor(() => {
expect(screen.getByTestId('record-panel')).toBeInTheDocument()
expect(screen.getByTestId('test-run-panel')).toBeInTheDocument()
expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument()
expect(screen.getByTestId('preview-panel')).toBeInTheDocument()
expect(screen.getByTestId('input-field-editor-panel')).toBeInTheDocument()
expect(screen.getByTestId('input-field-panel')).toBeInTheDocument()
})
})
})
})
// ============================================================================
// URL Generator Functions Tests
// ============================================================================
describe('URL Generator Functions', () => {
beforeEach(() => {
vi.clearAllMocks()
setupMocks()
})
it('should return consistent URLs for same versionId', async () => {
// Arrange
setupMocks({ pipelineId: 'stable-pipeline' })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
const deleteUrl1 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-x')
const deleteUrl2 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-x')
expect(deleteUrl1).toBe(deleteUrl2)
})
})
it('should return different URLs for different versionIds', async () => {
// Arrange
setupMocks({ pipelineId: 'stable-pipeline' })
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
const deleteUrl1 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1')
const deleteUrl2 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-2')
expect(deleteUrl1).not.toBe(deleteUrl2)
expect(deleteUrl1).toBe('/rag/pipelines/stable-pipeline/workflows/version-1')
expect(deleteUrl2).toBe('/rag/pipelines/stable-pipeline/workflows/version-2')
})
})
})
// ============================================================================
// Type Safety Tests
// ============================================================================
describe('Type Safety', () => {
beforeEach(() => {
vi.clearAllMocks()
setupMocks()
})
it('should pass correct PanelProps structure', async () => {
// Act
render(<RagPipelinePanel />)
// Assert - Check structure matches PanelProps
await waitFor(() => {
expect(capturedPanelProps).toHaveProperty('components')
expect(capturedPanelProps).toHaveProperty('versionHistoryPanelProps')
expect(capturedPanelProps?.components).toHaveProperty('left')
expect(capturedPanelProps?.components).toHaveProperty('right')
})
})
it('should pass correct versionHistoryPanelProps structure', async () => {
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps).toHaveProperty('getVersionListUrl')
expect(capturedPanelProps?.versionHistoryPanelProps).toHaveProperty('deleteVersionUrl')
expect(capturedPanelProps?.versionHistoryPanelProps).toHaveProperty('updateVersionUrl')
expect(capturedPanelProps?.versionHistoryPanelProps).toHaveProperty('latestVersionId')
})
})
})
// ============================================================================
// Performance Tests
// ============================================================================
describe('Performance', () => {
beforeEach(() => {
vi.clearAllMocks()
setupMocks()
})
it('should handle multiple rerenders without issues', async () => {
// Arrange
const { rerender } = render(<RagPipelinePanel />)
// Act - Multiple rerenders
for (let i = 0; i < 10; i++)
rerender(<RagPipelinePanel />)
// Assert - Component should still work
await waitFor(() => {
expect(screen.getByTestId('workflow-panel')).toBeInTheDocument()
})
})
})
// ============================================================================
// Integration Tests
// ============================================================================
describe('Integration Tests', () => {
beforeEach(() => {
vi.clearAllMocks()
setupMocks()
})
it('should pass correct components to Panel', async () => {
// Arrange
setupMocks({
historyWorkflowData: { id: 'h1' },
showInputFieldPanel: true,
})
// Act
render(<RagPipelinePanel />)
// Assert
await waitFor(() => {
expect(capturedPanelProps?.components?.left).toBeDefined()
expect(capturedPanelProps?.components?.right).toBeDefined()
// Check that the components are React elements
expect(React.isValidElement(capturedPanelProps?.components?.left)).toBe(true)
expect(React.isValidElement(capturedPanelProps?.components?.right)).toBe(true)
})
})
it('should correctly consume all store selectors', async () => {
// Arrange
setupMocks({
historyWorkflowData: { id: 'test-history' },
showDebugAndPreviewPanel: true,
showGlobalVariablePanel: true,
showInputFieldPanel: true,
showInputFieldPreviewPanel: true,
inputFieldEditPanelProps: { onClose: vi.fn(), onSubmit: vi.fn() },
pipelineId: 'integration-test-pipeline',
})
// Act
render(<RagPipelinePanel />)
// Assert - All store-dependent rendering should work
await waitFor(() => {
expect(screen.getByTestId('record-panel')).toBeInTheDocument()
expect(screen.getByTestId('test-run-panel')).toBeInTheDocument()
expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument()
expect(screen.getByTestId('input-field-panel')).toBeInTheDocument()
expect(screen.getByTestId('preview-panel')).toBeInTheDocument()
expect(screen.getByTestId('input-field-editor-panel')).toBeInTheDocument()
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe(
'/rag/pipelines/integration-test-pipeline/workflows',
)
})
})
})
@@ -49,6 +49,7 @@ const InputFieldEditorPanel = ({
</div>
<button
type="button"
data-testid="input-field-editor-close-btn"
className="absolute right-2.5 top-2.5 flex size-8 items-center justify-center"
onClick={onClose}
>
@@ -53,6 +53,7 @@ const FieldList = ({
{LabelRightContent}
</div>
<ActionButton
data-testid="field-list-add-btn"
onClick={() => handleOpenInputFieldEditor()}
disabled={readonly}
className={cn(readonly && 'cursor-not-allowed')}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,937 @@
import type { WorkflowRunningData } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { ChunkingMode } from '@/models/datasets'
import Header from './header'
// Import components after mocks
import TestRunPanel from './index'
// ============================================================================
// Mocks
// ============================================================================
// Mock workflow store
const mockIsPreparingDataSource = vi.fn(() => true)
const mockSetIsPreparingDataSource = vi.fn()
const mockWorkflowRunningData = vi.fn<() => WorkflowRunningData | undefined>(() => undefined)
const mockPipelineId = 'test-pipeline-id'
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
isPreparingDataSource: mockIsPreparingDataSource(),
workflowRunningData: mockWorkflowRunningData(),
pipelineId: mockPipelineId,
}
return selector(state)
},
useWorkflowStore: () => ({
getState: () => ({
isPreparingDataSource: mockIsPreparingDataSource(),
setIsPreparingDataSource: mockSetIsPreparingDataSource,
}),
}),
}))
// Mock workflow interactions
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowInteractions: () => ({
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
}),
useWorkflowRun: () => ({
handleRun: vi.fn(),
}),
useToolIcon: () => 'mock-tool-icon',
}))
// Mock data source provider
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store/provider', () => ({
default: ({ children }: { children: React.ReactNode }) => <div data-testid="data-source-provider">{children}</div>,
}))
// Mock Preparation component
vi.mock('./preparation', () => ({
default: () => <div data-testid="preparation-component">Preparation</div>,
}))
// Mock Result component (for TestRunPanel tests only)
vi.mock('./result', () => ({
default: () => <div data-testid="result-component">Result</div>,
}))
// Mock ResultPanel from workflow
vi.mock('@/app/components/workflow/run/result-panel', () => ({
default: (props: Record<string, unknown>) => (
<div data-testid="result-panel">
ResultPanel -
{' '}
{props.status as string}
</div>
),
}))
// Mock TracingPanel from workflow
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
default: (props: { list: unknown[] }) => (
<div data-testid="tracing-panel">
TracingPanel -
{' '}
{props.list?.length ?? 0}
{' '}
items
</div>
),
}))
// Mock Loading component
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading">Loading...</div>,
}))
// Mock config
vi.mock('@/config', () => ({
RAG_PIPELINE_PREVIEW_CHUNK_NUM: 5,
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createMockWorkflowRunningData = (overrides: Partial<WorkflowRunningData> = {}): WorkflowRunningData => ({
result: {
status: WorkflowRunningStatus.Succeeded,
outputs: '{"test": "output"}',
outputs_truncated: false,
inputs: '{"test": "input"}',
inputs_truncated: false,
process_data_truncated: false,
error: undefined,
elapsed_time: 1000,
total_tokens: 100,
created_at: Date.now(),
created_by: 'Test User',
total_steps: 5,
exceptions_count: 0,
},
tracing: [],
...overrides,
})
const createMockGeneralOutputs = (chunkContents: string[] = ['chunk1', 'chunk2']) => ({
chunk_structure: ChunkingMode.text,
preview: chunkContents.map(content => ({ content })),
})
const createMockParentChildOutputs = (parentMode: 'paragraph' | 'full-doc' = 'paragraph') => ({
chunk_structure: ChunkingMode.parentChild,
parent_mode: parentMode,
preview: [
{ content: 'parent1', child_chunks: ['child1', 'child2'] },
{ content: 'parent2', child_chunks: ['child3', 'child4'] },
],
})
const createMockQAOutputs = () => ({
chunk_structure: ChunkingMode.qa,
qa_preview: [
{ question: 'Q1', answer: 'A1' },
{ question: 'Q2', answer: 'A2' },
],
})
// ============================================================================
// TestRunPanel Component Tests
// ============================================================================
describe('TestRunPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsPreparingDataSource.mockReturnValue(true)
mockWorkflowRunningData.mockReturnValue(undefined)
})
// Basic rendering tests
describe('Rendering', () => {
it('should render with correct container styles', () => {
const { container } = render(<TestRunPanel />)
const panelDiv = container.firstChild as HTMLElement
expect(panelDiv).toHaveClass('relative', 'flex', 'h-full', 'w-[480px]', 'flex-col')
})
it('should render Header component', () => {
render(<TestRunPanel />)
expect(screen.getByText('datasetPipeline.testRun.title')).toBeInTheDocument()
})
})
// Conditional rendering based on isPreparingDataSource
describe('Conditional Content Rendering', () => {
it('should render Preparation inside DataSourceProvider when isPreparingDataSource is true', () => {
mockIsPreparingDataSource.mockReturnValue(true)
render(<TestRunPanel />)
expect(screen.getByTestId('data-source-provider')).toBeInTheDocument()
expect(screen.getByTestId('preparation-component')).toBeInTheDocument()
expect(screen.queryByTestId('result-component')).not.toBeInTheDocument()
})
it('should render Result when isPreparingDataSource is false', () => {
mockIsPreparingDataSource.mockReturnValue(false)
render(<TestRunPanel />)
expect(screen.getByTestId('result-component')).toBeInTheDocument()
expect(screen.queryByTestId('data-source-provider')).not.toBeInTheDocument()
expect(screen.queryByTestId('preparation-component')).not.toBeInTheDocument()
})
})
})
// ============================================================================
// Header Component Tests
// ============================================================================
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsPreparingDataSource.mockReturnValue(true)
})
// Rendering tests
describe('Rendering', () => {
it('should render title with correct translation key', () => {
render(<Header />)
expect(screen.getByText('datasetPipeline.testRun.title')).toBeInTheDocument()
})
it('should render close button', () => {
render(<Header />)
const closeButton = screen.getByRole('button')
expect(closeButton).toBeInTheDocument()
})
it('should have correct layout classes', () => {
const { container } = render(<Header />)
const headerDiv = container.firstChild as HTMLElement
expect(headerDiv).toHaveClass('flex', 'items-center', 'gap-x-2', 'pl-4', 'pr-3', 'pt-4')
})
})
// Close button interactions
describe('Close Button Interaction', () => {
it('should call setIsPreparingDataSource(false) and handleCancelDebugAndPreviewPanel when clicked and isPreparingDataSource is true', () => {
mockIsPreparingDataSource.mockReturnValue(true)
render(<Header />)
const closeButton = screen.getByRole('button')
fireEvent.click(closeButton)
expect(mockSetIsPreparingDataSource).toHaveBeenCalledWith(false)
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
})
it('should only call handleCancelDebugAndPreviewPanel when isPreparingDataSource is false', () => {
mockIsPreparingDataSource.mockReturnValue(false)
render(<Header />)
const closeButton = screen.getByRole('button')
fireEvent.click(closeButton)
expect(mockSetIsPreparingDataSource).not.toHaveBeenCalled()
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
})
})
})
// ============================================================================
// Result Component Tests (Real Implementation)
// ============================================================================
// Unmock Result for these tests
vi.doUnmock('./result')
describe('Result', () => {
// Dynamically import Result to get real implementation
let Result: typeof import('./result').default
beforeAll(async () => {
const resultModule = await import('./result')
Result = resultModule.default
})
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowRunningData.mockReturnValue(undefined)
})
// Rendering tests
describe('Rendering', () => {
it('should render with RESULT tab active by default', async () => {
render(<Result />)
await waitFor(() => {
const resultTab = screen.getByRole('button', { name: /runLog\.result/i })
expect(resultTab).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
})
})
it('should render all three tabs', () => {
render(<Result />)
expect(screen.getByRole('button', { name: /runLog\.result/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /runLog\.detail/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /runLog\.tracing/i })).toBeInTheDocument()
})
})
// Tab switching tests
describe('Tab Switching', () => {
it('should switch to DETAIL tab when clicked', async () => {
mockWorkflowRunningData.mockReturnValue(createMockWorkflowRunningData())
render(<Result />)
const detailTab = screen.getByRole('button', { name: /runLog\.detail/i })
fireEvent.click(detailTab)
await waitFor(() => {
expect(screen.getByTestId('result-panel')).toBeInTheDocument()
})
})
it('should switch to TRACING tab when clicked', async () => {
mockWorkflowRunningData.mockReturnValue(createMockWorkflowRunningData({ tracing: [{ id: '1' }] as unknown as WorkflowRunningData['tracing'] }))
render(<Result />)
const tracingTab = screen.getByRole('button', { name: /runLog\.tracing/i })
fireEvent.click(tracingTab)
await waitFor(() => {
expect(screen.getByTestId('tracing-panel')).toBeInTheDocument()
})
})
})
// Loading states
describe('Loading States', () => {
it('should show loading in DETAIL tab when no result data', async () => {
mockWorkflowRunningData.mockReturnValue({
result: undefined as unknown as WorkflowRunningData['result'],
tracing: [],
})
render(<Result />)
const detailTab = screen.getByRole('button', { name: /runLog\.detail/i })
fireEvent.click(detailTab)
await waitFor(() => {
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
})
it('should show loading in TRACING tab when no tracing data', async () => {
mockWorkflowRunningData.mockReturnValue(createMockWorkflowRunningData({ tracing: [] }))
render(<Result />)
const tracingTab = screen.getByRole('button', { name: /runLog\.tracing/i })
fireEvent.click(tracingTab)
await waitFor(() => {
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
})
})
})
// ============================================================================
// ResultPreview Component Tests
// ============================================================================
// We need to import ResultPreview directly
vi.doUnmock('./result/result-preview')
describe('ResultPreview', () => {
let ResultPreview: typeof import('./result/result-preview').default
beforeAll(async () => {
const previewModule = await import('./result/result-preview')
ResultPreview = previewModule.default
})
const mockOnSwitchToDetail = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// Loading state
describe('Loading State', () => {
it('should show loading spinner when isRunning is true and no outputs', () => {
render(
<ResultPreview
isRunning={true}
outputs={undefined}
error={undefined}
onSwitchToDetail={mockOnSwitchToDetail}
/>,
)
expect(screen.getByText('pipeline.result.resultPreview.loading')).toBeInTheDocument()
})
it('should not show loading when outputs are available', () => {
render(
<ResultPreview
isRunning={true}
outputs={createMockGeneralOutputs()}
error={undefined}
onSwitchToDetail={mockOnSwitchToDetail}
/>,
)
expect(screen.queryByText('pipeline.result.resultPreview.loading')).not.toBeInTheDocument()
})
})
// Error state
describe('Error State', () => {
it('should show error message when not running and has error', () => {
render(
<ResultPreview
isRunning={false}
outputs={undefined}
error="Test error message"
onSwitchToDetail={mockOnSwitchToDetail}
/>,
)
expect(screen.getByText('pipeline.result.resultPreview.error')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'pipeline.result.resultPreview.viewDetails' })).toBeInTheDocument()
})
it('should call onSwitchToDetail when View Details button is clicked', () => {
render(
<ResultPreview
isRunning={false}
outputs={undefined}
error="Test error message"
onSwitchToDetail={mockOnSwitchToDetail}
/>,
)
const viewDetailsButton = screen.getByRole('button', { name: 'pipeline.result.resultPreview.viewDetails' })
fireEvent.click(viewDetailsButton)
expect(mockOnSwitchToDetail).toHaveBeenCalledTimes(1)
})
it('should not show error when still running', () => {
render(
<ResultPreview
isRunning={true}
outputs={undefined}
error="Test error message"
onSwitchToDetail={mockOnSwitchToDetail}
/>,
)
expect(screen.queryByText('pipeline.result.resultPreview.error')).not.toBeInTheDocument()
})
})
// Success state with outputs
describe('Success State with Outputs', () => {
it('should render chunk content when outputs are available', () => {
render(
<ResultPreview
isRunning={false}
outputs={createMockGeneralOutputs(['test chunk content'])}
error={undefined}
onSwitchToDetail={mockOnSwitchToDetail}
/>,
)
// Check that chunk content is rendered (the real ChunkCardList renders the content)
expect(screen.getByText('test chunk content')).toBeInTheDocument()
})
it('should render multiple chunks when provided', () => {
render(
<ResultPreview
isRunning={false}
outputs={createMockGeneralOutputs(['chunk one', 'chunk two'])}
error={undefined}
onSwitchToDetail={mockOnSwitchToDetail}
/>,
)
expect(screen.getByText('chunk one')).toBeInTheDocument()
expect(screen.getByText('chunk two')).toBeInTheDocument()
})
it('should show footer tip', () => {
render(
<ResultPreview
isRunning={false}
outputs={createMockGeneralOutputs()}
error={undefined}
onSwitchToDetail={mockOnSwitchToDetail}
/>,
)
expect(screen.getByText(/pipeline\.result\.resultPreview\.footerTip/)).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty outputs gracefully', () => {
render(
<ResultPreview
isRunning={false}
outputs={null}
error={undefined}
onSwitchToDetail={mockOnSwitchToDetail}
/>,
)
// Should not crash and should not show chunk card list
expect(screen.queryByTestId('chunk-card-list')).not.toBeInTheDocument()
})
it('should handle undefined outputs', () => {
render(
<ResultPreview
isRunning={false}
outputs={undefined}
error={undefined}
onSwitchToDetail={mockOnSwitchToDetail}
/>,
)
expect(screen.queryByTestId('chunk-card-list')).not.toBeInTheDocument()
})
})
})
// ============================================================================
// Tabs Component Tests
// ============================================================================
vi.doUnmock('./result/tabs')
describe('Tabs', () => {
let Tabs: typeof import('./result/tabs').default
beforeAll(async () => {
const tabsModule = await import('./result/tabs')
Tabs = tabsModule.default
})
const mockSwitchTab = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render all three tabs', () => {
render(
<Tabs
currentTab="RESULT"
workflowRunningData={createMockWorkflowRunningData()}
switchTab={mockSwitchTab}
/>,
)
expect(screen.getByRole('button', { name: /runLog\.result/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /runLog\.detail/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /runLog\.tracing/i })).toBeInTheDocument()
})
})
// Active tab styling
describe('Active Tab Styling', () => {
it('should highlight RESULT tab when currentTab is RESULT', () => {
render(
<Tabs
currentTab="RESULT"
workflowRunningData={createMockWorkflowRunningData()}
switchTab={mockSwitchTab}
/>,
)
const resultTab = screen.getByRole('button', { name: /runLog\.result/i })
expect(resultTab).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
})
it('should highlight DETAIL tab when currentTab is DETAIL', () => {
render(
<Tabs
currentTab="DETAIL"
workflowRunningData={createMockWorkflowRunningData()}
switchTab={mockSwitchTab}
/>,
)
const detailTab = screen.getByRole('button', { name: /runLog\.detail/i })
expect(detailTab).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
})
})
// Tab click handling
describe('Tab Click Handling', () => {
it('should call switchTab with RESULT when RESULT tab is clicked', () => {
render(
<Tabs
currentTab="DETAIL"
workflowRunningData={createMockWorkflowRunningData()}
switchTab={mockSwitchTab}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /runLog\.result/i }))
expect(mockSwitchTab).toHaveBeenCalledWith('RESULT')
})
it('should call switchTab with DETAIL when DETAIL tab is clicked', () => {
render(
<Tabs
currentTab="RESULT"
workflowRunningData={createMockWorkflowRunningData()}
switchTab={mockSwitchTab}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /runLog\.detail/i }))
expect(mockSwitchTab).toHaveBeenCalledWith('DETAIL')
})
it('should call switchTab with TRACING when TRACING tab is clicked', () => {
render(
<Tabs
currentTab="RESULT"
workflowRunningData={createMockWorkflowRunningData()}
switchTab={mockSwitchTab}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /runLog\.tracing/i }))
expect(mockSwitchTab).toHaveBeenCalledWith('TRACING')
})
})
// Disabled state when no data
describe('Disabled State', () => {
it('should disable tabs when workflowRunningData is undefined', () => {
render(
<Tabs
currentTab="RESULT"
workflowRunningData={undefined}
switchTab={mockSwitchTab}
/>,
)
const resultTab = screen.getByRole('button', { name: /runLog\.result/i })
expect(resultTab).toBeDisabled()
})
})
})
// ============================================================================
// Tab Component Tests
// ============================================================================
vi.doUnmock('./result/tabs/tab')
describe('Tab', () => {
let Tab: typeof import('./result/tabs/tab').default
beforeAll(async () => {
const tabModule = await import('./result/tabs/tab')
Tab = tabModule.default
})
const mockOnClick = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render tab with label', () => {
render(
<Tab
isActive={false}
label="Test Tab"
value="TEST"
workflowRunningData={createMockWorkflowRunningData()}
onClick={mockOnClick}
/>,
)
expect(screen.getByRole('button', { name: 'Test Tab' })).toBeInTheDocument()
})
})
// Active state styling
describe('Active State', () => {
it('should have active styles when isActive is true', () => {
render(
<Tab
isActive={true}
label="Active Tab"
value="TEST"
workflowRunningData={createMockWorkflowRunningData()}
onClick={mockOnClick}
/>,
)
const tab = screen.getByRole('button')
expect(tab).toHaveClass('border-util-colors-blue-brand-blue-brand-600', 'text-text-primary')
})
it('should have inactive styles when isActive is false', () => {
render(
<Tab
isActive={false}
label="Inactive Tab"
value="TEST"
workflowRunningData={createMockWorkflowRunningData()}
onClick={mockOnClick}
/>,
)
const tab = screen.getByRole('button')
expect(tab).toHaveClass('border-transparent', 'text-text-tertiary')
})
})
// Click handling
describe('Click Handling', () => {
it('should call onClick with value when clicked', () => {
render(
<Tab
isActive={false}
label="Test Tab"
value="MY_VALUE"
workflowRunningData={createMockWorkflowRunningData()}
onClick={mockOnClick}
/>,
)
fireEvent.click(screen.getByRole('button'))
expect(mockOnClick).toHaveBeenCalledWith('MY_VALUE')
})
it('should not call onClick when disabled (no workflowRunningData)', () => {
render(
<Tab
isActive={false}
label="Test Tab"
value="MY_VALUE"
workflowRunningData={undefined}
onClick={mockOnClick}
/>,
)
const tab = screen.getByRole('button')
fireEvent.click(tab)
// The click handler is still called, but button is disabled
expect(tab).toBeDisabled()
})
})
// Disabled state
describe('Disabled State', () => {
it('should be disabled when workflowRunningData is undefined', () => {
render(
<Tab
isActive={false}
label="Test Tab"
value="TEST"
workflowRunningData={undefined}
onClick={mockOnClick}
/>,
)
const tab = screen.getByRole('button')
expect(tab).toBeDisabled()
expect(tab).toHaveClass('opacity-30')
})
it('should not be disabled when workflowRunningData is provided', () => {
render(
<Tab
isActive={false}
label="Test Tab"
value="TEST"
workflowRunningData={createMockWorkflowRunningData()}
onClick={mockOnClick}
/>,
)
const tab = screen.getByRole('button')
expect(tab).not.toBeDisabled()
})
})
})
// ============================================================================
// formatPreviewChunks Utility Tests
// ============================================================================
describe('formatPreviewChunks', () => {
let formatPreviewChunks: typeof import('./result/result-preview/utils').formatPreviewChunks
beforeAll(async () => {
const utilsModule = await import('./result/result-preview/utils')
formatPreviewChunks = utilsModule.formatPreviewChunks
})
// Edge cases
describe('Edge Cases', () => {
it('should return undefined for null outputs', () => {
expect(formatPreviewChunks(null)).toBeUndefined()
})
it('should return undefined for undefined outputs', () => {
expect(formatPreviewChunks(undefined)).toBeUndefined()
})
it('should return undefined for unknown chunk structure', () => {
const outputs = {
chunk_structure: 'unknown_mode',
preview: [],
}
expect(formatPreviewChunks(outputs)).toBeUndefined()
})
})
// General (text) chunks
describe('General Chunks (ChunkingMode.text)', () => {
it('should format general chunks correctly', () => {
const outputs = createMockGeneralOutputs(['content1', 'content2', 'content3'])
const result = formatPreviewChunks(outputs)
expect(result).toEqual(['content1', 'content2', 'content3'])
})
it('should limit to RAG_PIPELINE_PREVIEW_CHUNK_NUM chunks', () => {
const manyChunks = Array.from({ length: 10 }, (_, i) => `chunk${i}`)
const outputs = createMockGeneralOutputs(manyChunks)
const result = formatPreviewChunks(outputs) as string[]
// RAG_PIPELINE_PREVIEW_CHUNK_NUM is mocked to 5
expect(result).toHaveLength(5)
expect(result).toEqual(['chunk0', 'chunk1', 'chunk2', 'chunk3', 'chunk4'])
})
it('should handle empty preview array', () => {
const outputs = createMockGeneralOutputs([])
const result = formatPreviewChunks(outputs)
expect(result).toEqual([])
})
})
// Parent-child chunks
describe('Parent-Child Chunks (ChunkingMode.parentChild)', () => {
it('should format paragraph mode parent-child chunks correctly', () => {
const outputs = createMockParentChildOutputs('paragraph')
const result = formatPreviewChunks(outputs)
expect(result).toEqual({
parent_child_chunks: [
{ parent_content: 'parent1', child_contents: ['child1', 'child2'], parent_mode: 'paragraph' },
{ parent_content: 'parent2', child_contents: ['child3', 'child4'], parent_mode: 'paragraph' },
],
parent_mode: 'paragraph',
})
})
it('should format full-doc mode parent-child chunks and limit child chunks', () => {
const outputs = {
chunk_structure: ChunkingMode.parentChild,
parent_mode: 'full-doc' as const,
preview: [
{
content: 'full-doc-parent',
child_chunks: Array.from({ length: 10 }, (_, i) => `child${i}`),
},
],
}
const result = formatPreviewChunks(outputs)
expect(result).toEqual({
parent_child_chunks: [
{
parent_content: 'full-doc-parent',
child_contents: ['child0', 'child1', 'child2', 'child3', 'child4'], // Limited to 5
parent_mode: 'full-doc',
},
],
parent_mode: 'full-doc',
})
})
})
// QA chunks
describe('QA Chunks (ChunkingMode.qa)', () => {
it('should format QA chunks correctly', () => {
const outputs = createMockQAOutputs()
const result = formatPreviewChunks(outputs)
expect(result).toEqual({
qa_chunks: [
{ question: 'Q1', answer: 'A1' },
{ question: 'Q2', answer: 'A2' },
],
})
})
it('should limit QA chunks to RAG_PIPELINE_PREVIEW_CHUNK_NUM', () => {
const outputs = {
chunk_structure: ChunkingMode.qa,
qa_preview: Array.from({ length: 10 }, (_, i) => ({
question: `Q${i}`,
answer: `A${i}`,
})),
}
const result = formatPreviewChunks(outputs) as { qa_chunks: Array<{ question: string, answer: string }> }
expect(result.qa_chunks).toHaveLength(5)
})
})
})
// ============================================================================
// Types Tests
// ============================================================================
describe('Types', () => {
describe('TestRunStep Enum', () => {
it('should have correct enum values', async () => {
const { TestRunStep } = await import('./types')
expect(TestRunStep.dataSource).toBe('dataSource')
expect(TestRunStep.documentProcessing).toBe('documentProcessing')
})
})
})
@@ -0,0 +1,549 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Actions from './index'
// ============================================================================
// Actions Component Tests
// ============================================================================
describe('Actions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// -------------------------------------------------------------------------
// Rendering Tests
// -------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions handleNextStep={handleNextStep} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render button with translated text', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions handleNextStep={handleNextStep} />)
// Assert - Translation mock returns key with namespace prefix
expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument()
})
it('should render with correct container structure', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { container } = render(<Actions handleNextStep={handleNextStep} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('flex')
expect(wrapper.className).toContain('justify-end')
expect(wrapper.className).toContain('p-4')
expect(wrapper.className).toContain('pt-2')
})
it('should render span with px-0.5 class around text', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { container } = render(<Actions handleNextStep={handleNextStep} />)
// Assert
const span = container.querySelector('span')
expect(span).toBeInTheDocument()
expect(span?.className).toContain('px-0.5')
})
})
// -------------------------------------------------------------------------
// Props Variations Tests
// -------------------------------------------------------------------------
describe('Props Variations', () => {
it('should pass disabled=true to button when disabled prop is true', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions disabled={true} handleNextStep={handleNextStep} />)
// Assert
expect(screen.getByRole('button')).toBeDisabled()
})
it('should pass disabled=false to button when disabled prop is false', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions disabled={false} handleNextStep={handleNextStep} />)
// Assert
expect(screen.getByRole('button')).not.toBeDisabled()
})
it('should not disable button when disabled prop is undefined', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions handleNextStep={handleNextStep} />)
// Assert
expect(screen.getByRole('button')).not.toBeDisabled()
})
it('should handle disabled switching from true to false', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { rerender } = render(
<Actions disabled={true} handleNextStep={handleNextStep} />,
)
// Assert - Initially disabled
expect(screen.getByRole('button')).toBeDisabled()
// Act - Rerender with disabled=false
rerender(<Actions disabled={false} handleNextStep={handleNextStep} />)
// Assert - Now enabled
expect(screen.getByRole('button')).not.toBeDisabled()
})
it('should handle disabled switching from false to true', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { rerender } = render(
<Actions disabled={false} handleNextStep={handleNextStep} />,
)
// Assert - Initially enabled
expect(screen.getByRole('button')).not.toBeDisabled()
// Act - Rerender with disabled=true
rerender(<Actions disabled={true} handleNextStep={handleNextStep} />)
// Assert - Now disabled
expect(screen.getByRole('button')).toBeDisabled()
})
it('should handle undefined disabled becoming true', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { rerender } = render(
<Actions handleNextStep={handleNextStep} />,
)
// Assert - Initially not disabled (undefined)
expect(screen.getByRole('button')).not.toBeDisabled()
// Act - Rerender with disabled=true
rerender(<Actions disabled={true} handleNextStep={handleNextStep} />)
// Assert - Now disabled
expect(screen.getByRole('button')).toBeDisabled()
})
})
// -------------------------------------------------------------------------
// User Interaction Tests
// -------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call handleNextStep when button is clicked', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions handleNextStep={handleNextStep} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleNextStep).toHaveBeenCalledTimes(1)
})
it('should call handleNextStep exactly once per click', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions handleNextStep={handleNextStep} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleNextStep).toHaveBeenCalled()
expect(handleNextStep.mock.calls).toHaveLength(1)
})
it('should call handleNextStep multiple times on multiple clicks', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions handleNextStep={handleNextStep} />)
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.click(button)
// Assert
expect(handleNextStep).toHaveBeenCalledTimes(3)
})
it('should not call handleNextStep when button is disabled and clicked', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions disabled={true} handleNextStep={handleNextStep} />)
fireEvent.click(screen.getByRole('button'))
// Assert - Disabled button should not trigger onClick
expect(handleNextStep).not.toHaveBeenCalled()
})
it('should handle rapid clicks when not disabled', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions handleNextStep={handleNextStep} />)
const button = screen.getByRole('button')
// Simulate rapid clicks
for (let i = 0; i < 10; i++)
fireEvent.click(button)
// Assert
expect(handleNextStep).toHaveBeenCalledTimes(10)
})
})
// -------------------------------------------------------------------------
// Callback Stability Tests
// -------------------------------------------------------------------------
describe('Callback Stability', () => {
it('should use the new handleNextStep when prop changes', () => {
// Arrange
const handleNextStep1 = vi.fn()
const handleNextStep2 = vi.fn()
// Act
const { rerender } = render(
<Actions handleNextStep={handleNextStep1} />,
)
fireEvent.click(screen.getByRole('button'))
rerender(<Actions handleNextStep={handleNextStep2} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleNextStep1).toHaveBeenCalledTimes(1)
expect(handleNextStep2).toHaveBeenCalledTimes(1)
})
it('should maintain functionality after rerender with same props', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { rerender } = render(
<Actions handleNextStep={handleNextStep} />,
)
fireEvent.click(screen.getByRole('button'))
rerender(<Actions handleNextStep={handleNextStep} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleNextStep).toHaveBeenCalledTimes(2)
})
it('should work correctly when handleNextStep changes multiple times', () => {
// Arrange
const handleNextStep1 = vi.fn()
const handleNextStep2 = vi.fn()
const handleNextStep3 = vi.fn()
// Act
const { rerender } = render(
<Actions handleNextStep={handleNextStep1} />,
)
fireEvent.click(screen.getByRole('button'))
rerender(<Actions handleNextStep={handleNextStep2} />)
fireEvent.click(screen.getByRole('button'))
rerender(<Actions handleNextStep={handleNextStep3} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleNextStep1).toHaveBeenCalledTimes(1)
expect(handleNextStep2).toHaveBeenCalledTimes(1)
expect(handleNextStep3).toHaveBeenCalledTimes(1)
})
})
// -------------------------------------------------------------------------
// Memoization Tests
// -------------------------------------------------------------------------
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
// Arrange
const handleNextStep = vi.fn()
// Act - Verify component is memoized by checking display name pattern
const { rerender } = render(
<Actions handleNextStep={handleNextStep} />,
)
// Rerender with same props should work without issues
rerender(<Actions handleNextStep={handleNextStep} />)
// Assert - Component should render correctly after rerender
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should not break when props remain the same across rerenders', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { rerender } = render(
<Actions disabled={false} handleNextStep={handleNextStep} />,
)
// Multiple rerenders with same props
for (let i = 0; i < 5; i++) {
rerender(<Actions disabled={false} handleNextStep={handleNextStep} />)
}
// Assert - Should still function correctly
fireEvent.click(screen.getByRole('button'))
expect(handleNextStep).toHaveBeenCalledTimes(1)
})
it('should update correctly when only disabled prop changes', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { rerender } = render(
<Actions disabled={false} handleNextStep={handleNextStep} />,
)
// Assert - Initially not disabled
expect(screen.getByRole('button')).not.toBeDisabled()
// Act - Change only disabled prop
rerender(<Actions disabled={true} handleNextStep={handleNextStep} />)
// Assert - Should reflect the new disabled state
expect(screen.getByRole('button')).toBeDisabled()
})
it('should update correctly when only handleNextStep prop changes', () => {
// Arrange
const handleNextStep1 = vi.fn()
const handleNextStep2 = vi.fn()
// Act
const { rerender } = render(
<Actions disabled={false} handleNextStep={handleNextStep1} />,
)
fireEvent.click(screen.getByRole('button'))
expect(handleNextStep1).toHaveBeenCalledTimes(1)
// Act - Change only handleNextStep prop
rerender(<Actions disabled={false} handleNextStep={handleNextStep2} />)
fireEvent.click(screen.getByRole('button'))
// Assert - New callback should be used
expect(handleNextStep1).toHaveBeenCalledTimes(1)
expect(handleNextStep2).toHaveBeenCalledTimes(1)
})
})
// -------------------------------------------------------------------------
// Edge Cases Tests
// -------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should call handleNextStep even if it has side effects', () => {
// Arrange
let sideEffectValue = 0
const handleNextStep = vi.fn(() => {
sideEffectValue = 42
})
// Act
render(<Actions handleNextStep={handleNextStep} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleNextStep).toHaveBeenCalledTimes(1)
expect(sideEffectValue).toBe(42)
})
it('should handle handleNextStep that returns a value', () => {
// Arrange
const handleNextStep = vi.fn(() => 'return value')
// Act
render(<Actions handleNextStep={handleNextStep} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleNextStep).toHaveBeenCalledTimes(1)
expect(handleNextStep).toHaveReturnedWith('return value')
})
it('should handle handleNextStep that is async', async () => {
// Arrange
const handleNextStep = vi.fn().mockResolvedValue(undefined)
// Act
render(<Actions handleNextStep={handleNextStep} />)
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleNextStep).toHaveBeenCalledTimes(1)
})
it('should render correctly with both disabled=true and handleNextStep', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions disabled={true} handleNextStep={handleNextStep} />)
// Assert
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
it('should handle component unmount gracefully', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { unmount } = render(<Actions handleNextStep={handleNextStep} />)
// Assert - Unmount should not throw
expect(() => unmount()).not.toThrow()
})
it('should handle disabled as boolean-like falsy value', () => {
// Arrange
const handleNextStep = vi.fn()
// Act - Test with explicit false
render(<Actions disabled={false} handleNextStep={handleNextStep} />)
// Assert
expect(screen.getByRole('button')).not.toBeDisabled()
})
})
// -------------------------------------------------------------------------
// Accessibility Tests
// -------------------------------------------------------------------------
describe('Accessibility', () => {
it('should have button element that can receive focus', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions handleNextStep={handleNextStep} />)
const button = screen.getByRole('button')
// Assert - Button should be focusable (not disabled by default)
expect(button).not.toBeDisabled()
})
it('should indicate disabled state correctly', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
render(<Actions disabled={true} handleNextStep={handleNextStep} />)
// Assert
expect(screen.getByRole('button')).toHaveAttribute('disabled')
})
})
// -------------------------------------------------------------------------
// Integration Tests
// -------------------------------------------------------------------------
describe('Integration', () => {
it('should work in a typical workflow: enable -> click -> disable', () => {
// Arrange
const handleNextStep = vi.fn()
// Act - Start enabled
const { rerender } = render(
<Actions disabled={false} handleNextStep={handleNextStep} />,
)
// Assert - Can click when enabled
expect(screen.getByRole('button')).not.toBeDisabled()
fireEvent.click(screen.getByRole('button'))
expect(handleNextStep).toHaveBeenCalledTimes(1)
// Act - Disable after click (simulating loading state)
rerender(<Actions disabled={true} handleNextStep={handleNextStep} />)
// Assert - Cannot click when disabled
expect(screen.getByRole('button')).toBeDisabled()
fireEvent.click(screen.getByRole('button'))
expect(handleNextStep).toHaveBeenCalledTimes(1) // Still 1, not 2
// Act - Re-enable
rerender(<Actions disabled={false} handleNextStep={handleNextStep} />)
// Assert - Can click again
expect(screen.getByRole('button')).not.toBeDisabled()
fireEvent.click(screen.getByRole('button'))
expect(handleNextStep).toHaveBeenCalledTimes(2)
})
it('should maintain consistent rendering across multiple state changes', () => {
// Arrange
const handleNextStep = vi.fn()
// Act
const { rerender } = render(
<Actions disabled={false} handleNextStep={handleNextStep} />,
)
// Toggle disabled state multiple times
const states = [true, false, true, false, true]
states.forEach((disabled) => {
rerender(<Actions disabled={disabled} handleNextStep={handleNextStep} />)
if (disabled)
expect(screen.getByRole('button')).toBeDisabled()
else
expect(screen.getByRole('button')).not.toBeDisabled()
})
// Assert - Button should still render correctly
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument()
})
})
})
File diff suppressed because it is too large Load Diff
@@ -85,7 +85,11 @@ const PublishAsKnowledgePipelineModal = ({
>
<div className="title-2xl-semi-bold relative flex items-center p-6 pb-3 pr-14 text-text-primary">
{t('common.publishAs', { ns: 'pipeline' })}
<div className="absolute right-5 top-5 flex h-8 w-8 cursor-pointer items-center justify-center" onClick={onCancel}>
<div
data-testid="publish-modal-close-btn"
className="absolute right-5 top-5 flex h-8 w-8 cursor-pointer items-center justify-center"
onClick={onCancel}
>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
</div>
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -19,6 +19,7 @@ import { useDocLink } from '@/context/i18n'
import useDocumentTitle from '@/hooks/use-document-title'
import { fetchInitValidateStatus, fetchSetupStatus, login, setup } from '@/service/common'
import { cn } from '@/utils/classnames'
import { encryptPassword as encodePassword } from '@/utils/encryption'
import Loading from '../components/base/loading'
const accountFormSchema = z.object({
@@ -68,7 +69,7 @@ const InstallForm = () => {
url: '/login',
body: {
email: data.email,
password: data.password,
password: encodePassword(data.password),
},
})
@@ -0,0 +1,158 @@
import type { MockedFunction } from 'vitest'
import type { SystemFeatures } from '@/types/feature'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useLocale } from '@/context/i18n'
import { useSendMail } from '@/service/use-common'
import { defaultSystemFeatures } from '@/types/feature'
import Form from './input-mail'
const mockSubmitMail = vi.fn()
const mockOnSuccess = vi.fn()
type SystemFeaturesOverrides = Partial<Omit<SystemFeatures, 'branding'>> & {
branding?: Partial<SystemFeatures['branding']>
}
const buildSystemFeatures = (overrides: SystemFeaturesOverrides = {}): SystemFeatures => ({
...defaultSystemFeatures,
...overrides,
branding: {
...defaultSystemFeatures.branding,
...overrides.branding,
},
})
vi.mock('next/link', () => ({
default: ({ children, href, className, target, rel }: { children: React.ReactNode, href: string, className?: string, target?: string, rel?: string }) => (
<a href={href} className={className} target={target} rel={rel}>
{children}
</a>
),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useSendMail: vi.fn(),
}))
type UseSendMailResult = ReturnType<typeof useSendMail>
const mockUseGlobalPublicStore = useGlobalPublicStore as unknown as MockedFunction<typeof useGlobalPublicStore>
const mockUseLocale = useLocale as unknown as MockedFunction<typeof useLocale>
const mockUseSendMail = useSendMail as unknown as MockedFunction<typeof useSendMail>
const renderForm = ({
brandingEnabled = false,
isPending = false,
}: {
brandingEnabled?: boolean
isPending?: boolean
} = {}) => {
mockUseGlobalPublicStore.mockReturnValue({
systemFeatures: buildSystemFeatures({
branding: { enabled: brandingEnabled },
}),
})
mockUseLocale.mockReturnValue('en-US')
mockUseSendMail.mockReturnValue({
mutateAsync: mockSubmitMail,
isPending,
} as unknown as UseSendMailResult)
return render(<Form onSuccess={mockOnSuccess} />)
}
describe('InputMail Form', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSubmitMail.mockResolvedValue({ result: 'success', data: 'token' })
})
// Rendering baseline UI elements.
describe('Rendering', () => {
it('should render email input and submit button', () => {
renderForm()
expect(screen.getByLabelText('login.email')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'login.signup.verifyMail' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'login.signup.signIn' })).toBeInTheDocument()
})
})
// Prop-driven branding content visibility.
describe('Props', () => {
it('should show terms links when branding is disabled', () => {
renderForm({ brandingEnabled: false })
expect(screen.getByRole('link', { name: 'login.tos' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'login.pp' })).toBeInTheDocument()
})
it('should hide terms links when branding is enabled', () => {
renderForm({ brandingEnabled: true })
expect(screen.queryByRole('link', { name: 'login.tos' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'login.pp' })).not.toBeInTheDocument()
})
})
// Submission flow and mutation integration.
describe('User Interactions', () => {
it('should submit email and call onSuccess when mutation succeeds', async () => {
renderForm()
const input = screen.getByLabelText('login.email')
const button = screen.getByRole('button', { name: 'login.signup.verifyMail' })
fireEvent.change(input, { target: { value: 'test@example.com' } })
fireEvent.click(button)
expect(mockSubmitMail).toHaveBeenCalledWith({
email: 'test@example.com',
language: 'en-US',
})
await waitFor(() => {
expect(mockOnSuccess).toHaveBeenCalledWith('test@example.com', 'token')
})
})
})
// Validation and failure paths.
describe('Edge Cases', () => {
it('should block submission when email is invalid', () => {
const { container } = renderForm()
const form = container.querySelector('form')
const input = screen.getByLabelText('login.email')
fireEvent.change(input, { target: { value: 'invalid-email' } })
expect(form).not.toBeNull()
fireEvent.submit(form as HTMLFormElement)
expect(mockSubmitMail).not.toHaveBeenCalled()
expect(mockOnSuccess).not.toHaveBeenCalled()
})
it('should not call onSuccess when mutation does not succeed', async () => {
mockSubmitMail.mockResolvedValue({ result: 'failed', data: 'token' })
renderForm()
const input = screen.getByLabelText('login.email')
const button = screen.getByRole('button', { name: 'login.signup.verifyMail' })
fireEvent.change(input, { target: { value: 'test@example.com' } })
fireEvent.click(button)
await waitFor(() => {
expect(mockSubmitMail).toHaveBeenCalled()
})
expect(mockOnSuccess).not.toHaveBeenCalled()
})
})
})
+12 -6
View File
@@ -1,6 +1,5 @@
'use client'
import type { MailSendResponse } from '@/service/use-common'
import { noop } from 'es-toolkit/function'
import Link from 'next/link'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -27,6 +26,9 @@ export default function Form({
const { mutateAsync: submitMail, isPending } = useSendMail()
const handleSubmit = useCallback(async () => {
if (isPending)
return
if (!email) {
Toast.notify({ type: 'error', message: t('error.emailEmpty', { ns: 'login' }) })
return
@@ -41,10 +43,14 @@ export default function Form({
const res = await submitMail({ email, language: locale })
if ((res as MailSendResponse).result === 'success')
onSuccess(email, (res as MailSendResponse).data)
}, [email, locale, submitMail, t])
}, [email, locale, submitMail, t, isPending, onSuccess])
return (
<form onSubmit={noop}>
<form onSubmit={(e) => {
e.preventDefault()
handleSubmit()
}}
>
<div className="mb-3">
<label htmlFor="email" className="system-md-semibold my-2 text-text-secondary">
{t('email', { ns: 'login' })}
@@ -65,7 +71,7 @@ export default function Form({
<Button
tabIndex={2}
variant="primary"
onClick={handleSubmit}
type="submit"
disabled={isPending || !email}
className="w-full"
>
@@ -88,7 +94,7 @@ export default function Form({
<>
<div className="system-xs-regular mt-3 block w-full text-text-tertiary">
{t('tosDesc', { ns: 'login' })}
&nbsp;
&nbsp;
<Link
className="system-xs-medium text-text-secondary hover:underline"
target="_blank"
@@ -97,7 +103,7 @@ export default function Form({
>
{t('tos', { ns: 'login' })}
</Link>
&nbsp;&&nbsp;
&nbsp;&&nbsp;
<Link
className="system-xs-medium text-text-secondary hover:underline"
target="_blank"
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "أوقات الاتصال",
"modelProvider.card.buyQuota": "شراء حصة",
"modelProvider.card.callTimes": "أوقات الاتصال",
"modelProvider.card.modelAPI": "النماذج {{modelName}} تستخدم مفتاح واجهة برمجة التطبيقات.",
"modelProvider.card.modelNotSupported": "النماذج {{modelName}} غير مثبتة.",
"modelProvider.card.modelSupported": "النماذج {{modelName}} تستخدم هذا الحصة.",
"modelProvider.card.onTrial": "في التجربة",
"modelProvider.card.paid": "مدفوع",
"modelProvider.card.priorityUse": "أولوية الاستخدام",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "الرموز المجانية المتاحة المتبقية",
"modelProvider.rerankModel.key": "نموذج إعادة الترتيب",
"modelProvider.rerankModel.tip": "سيعيد نموذج إعادة الترتيب ترتيب قائمة المستندات المرشحة بناءً على المطابقة الدلالية مع استعلام المستخدم، مما يحسن نتائج الترتيب الدلالي",
"modelProvider.resetDate": "إعادة الضبط على {{date}}",
"modelProvider.searchModel": "نموذج البحث",
"modelProvider.selectModel": "اختر نموذجك",
"modelProvider.selector.emptySetting": "يرجى الانتقال إلى الإعدادات للتكوين",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "Anrufzeiten",
"modelProvider.card.buyQuota": "Kontingent kaufen",
"modelProvider.card.callTimes": "Anrufzeiten",
"modelProvider.card.modelAPI": "{{modelName}}-Modelle verwenden den API-Schlüssel.",
"modelProvider.card.modelNotSupported": "{{modelName}}-Modelle sind nicht installiert.",
"modelProvider.card.modelSupported": "{{modelName}}-Modelle verwenden dieses Kontingent.",
"modelProvider.card.onTrial": "In Probe",
"modelProvider.card.paid": "Bezahlt",
"modelProvider.card.priorityUse": "Priorisierte Nutzung",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "Verbleibende verfügbare kostenlose Token",
"modelProvider.rerankModel.key": "Rerank-Modell",
"modelProvider.rerankModel.tip": "Rerank-Modell wird die Kandidatendokumentenliste basierend auf der semantischen Übereinstimmung mit der Benutzeranfrage neu ordnen und die Ergebnisse der semantischen Rangordnung verbessern",
"modelProvider.resetDate": "Zurücksetzen bei {{date}}",
"modelProvider.searchModel": "Suchmodell",
"modelProvider.selectModel": "Wählen Sie Ihr Modell",
"modelProvider.selector.emptySetting": "Bitte gehen Sie zu den Einstellungen, um zu konfigurieren",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "Tiempos de llamada",
"modelProvider.card.buyQuota": "Comprar Cuota",
"modelProvider.card.callTimes": "Tiempos de llamada",
"modelProvider.card.modelAPI": "Los modelos {{modelName}} están usando la clave de API.",
"modelProvider.card.modelNotSupported": "Los modelos {{modelName}} no están instalados.",
"modelProvider.card.modelSupported": "Los modelos {{modelName}} están utilizando esta cuota.",
"modelProvider.card.onTrial": "En prueba",
"modelProvider.card.paid": "Pagado",
"modelProvider.card.priorityUse": "Uso prioritario",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "Tokens gratuitos restantes disponibles",
"modelProvider.rerankModel.key": "Modelo de Reordenar",
"modelProvider.rerankModel.tip": "El modelo de reordenar reordenará la lista de documentos candidatos basada en la coincidencia semántica con la consulta del usuario, mejorando los resultados de clasificación semántica",
"modelProvider.resetDate": "Reiniciar en {{date}}",
"modelProvider.searchModel": "Modelo de búsqueda",
"modelProvider.selectModel": "Selecciona tu modelo",
"modelProvider.selector.emptySetting": "Por favor ve a configuraciones para configurar",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "تعداد فراخوانی",
"modelProvider.card.buyQuota": "خرید سهمیه",
"modelProvider.card.callTimes": "تعداد فراخوانی",
"modelProvider.card.modelAPI": "مدل‌های {{modelName}} در حال استفاده از کلید API هستند.",
"modelProvider.card.modelNotSupported": "مدل‌های {{modelName}} نصب نشده‌اند.",
"modelProvider.card.modelSupported": "مدل‌های {{modelName}} از این سهمیه استفاده می‌کنند.",
"modelProvider.card.onTrial": "در حال آزمایش",
"modelProvider.card.paid": "پرداخت شده",
"modelProvider.card.priorityUse": "استفاده با اولویت",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "توکن‌های رایگان باقی‌مانده در دسترس",
"modelProvider.rerankModel.key": "مدل رتبه‌بندی مجدد",
"modelProvider.rerankModel.tip": "مدل رتبه‌بندی مجدد، لیست اسناد کاندید را بر اساس تطابق معنایی با پرسش کاربر مرتب می‌کند و نتایج رتبه‌بندی معنایی را بهبود می‌بخشد",
"modelProvider.resetDate": "بازنشانی در {{date}}",
"modelProvider.searchModel": "جستجوی مدل",
"modelProvider.selectModel": "مدل خود را انتخاب کنید",
"modelProvider.selector.emptySetting": "لطفاً به تنظیمات بروید تا پیکربندی کنید",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "Temps d'appel",
"modelProvider.card.buyQuota": "Acheter Quota",
"modelProvider.card.callTimes": "Temps d'appel",
"modelProvider.card.modelAPI": "Les modèles {{modelName}} utilisent la clé API.",
"modelProvider.card.modelNotSupported": "Les modèles {{modelName}} ne sont pas installés.",
"modelProvider.card.modelSupported": "Les modèles {{modelName}} utilisent ce quota.",
"modelProvider.card.onTrial": "En Essai",
"modelProvider.card.paid": "Payé",
"modelProvider.card.priorityUse": "Utilisation prioritaire",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "Tokens gratuits restants disponibles",
"modelProvider.rerankModel.key": "Modèle de Réorganisation",
"modelProvider.rerankModel.tip": "Le modèle de réorganisation réorganisera la liste des documents candidats en fonction de la correspondance sémantique avec la requête de l'utilisateur, améliorant ainsi les résultats du classement sémantique.",
"modelProvider.resetDate": "Réinitialiser sur {{date}}",
"modelProvider.searchModel": "Modèle de recherche",
"modelProvider.selectModel": "Sélectionnez votre modèle",
"modelProvider.selector.emptySetting": "Veuillez aller dans les paramètres pour configurer",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "कॉल समय",
"modelProvider.card.buyQuota": "कोटा खरीदें",
"modelProvider.card.callTimes": "कॉल समय",
"modelProvider.card.modelAPI": "{{modelName}} मॉडल एपीआई कुंजी का उपयोग कर रहे हैं।",
"modelProvider.card.modelNotSupported": "{{modelName}} मॉडल इंस्टॉल नहीं हैं।",
"modelProvider.card.modelSupported": "{{modelName}} मॉडल इस कोटा का उपयोग कर रहे हैं।",
"modelProvider.card.onTrial": "परीक्षण पर",
"modelProvider.card.paid": "भुगतान किया हुआ",
"modelProvider.card.priorityUse": "प्राथमिकता उपयोग",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "बचे हुए उपलब्ध मुफ्त टोकन",
"modelProvider.rerankModel.key": "रीरैंक मॉडल",
"modelProvider.rerankModel.tip": "रीरैंक मॉडल उपयोगकर्ता प्रश्न के साथ सांविधिक मेल के आधार पर उम्मीदवार दस्तावेज़ सूची को पुनः क्रमित करेगा, सांविधिक रैंकिंग के परिणामों में सुधार करेगा।",
"modelProvider.resetDate": "{{date}} पर रीसेट करें",
"modelProvider.searchModel": "खोज मॉडल",
"modelProvider.selectModel": "अपने मॉडल का चयन करें",
"modelProvider.selector.emptySetting": "कॉन्फ़िगर करने के लिए कृपया सेटिंग्स पर जाएं",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "Waktu panggilan",
"modelProvider.card.buyQuota": "Beli Kuota",
"modelProvider.card.callTimes": "Waktu panggilan",
"modelProvider.card.modelAPI": "Model {{modelName}} sedang menggunakan API Key.",
"modelProvider.card.modelNotSupported": "Model {{modelName}} tidak terpasang.",
"modelProvider.card.modelSupported": "Model {{modelName}} sedang menggunakan kuota ini.",
"modelProvider.card.onTrial": "Sedang Diadili",
"modelProvider.card.paid": "Dibayar",
"modelProvider.card.priorityUse": "Penggunaan prioritas",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "Token gratis yang masih tersedia",
"modelProvider.rerankModel.key": "Peringkat ulang Model",
"modelProvider.rerankModel.tip": "Model rerank akan menyusun ulang daftar dokumen kandidat berdasarkan kecocokan semantik dengan kueri pengguna, meningkatkan hasil peringkat semantik",
"modelProvider.resetDate": "Atur ulang pada {{date}}",
"modelProvider.searchModel": "Model pencarian",
"modelProvider.selectModel": "Pilih model Anda",
"modelProvider.selector.emptySetting": "Silakan buka pengaturan untuk mengonfigurasi",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "Numero di chiamate",
"modelProvider.card.buyQuota": "Acquista Quota",
"modelProvider.card.callTimes": "Numero di chiamate",
"modelProvider.card.modelAPI": "I modelli {{modelName}} stanno utilizzando la chiave API.",
"modelProvider.card.modelNotSupported": "I modelli {{modelName}} non sono installati.",
"modelProvider.card.modelSupported": "I modelli {{modelName}} stanno utilizzando questa quota.",
"modelProvider.card.onTrial": "In Prova",
"modelProvider.card.paid": "Pagato",
"modelProvider.card.priorityUse": "Uso prioritario",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "Token gratuiti rimanenti disponibili",
"modelProvider.rerankModel.key": "Modello di Rerank",
"modelProvider.rerankModel.tip": "Il modello di rerank riordinerà la lista dei documenti candidati basandosi sulla corrispondenza semantica con la query dell'utente, migliorando i risultati del ranking semantico",
"modelProvider.resetDate": "Reimposta su {{date}}",
"modelProvider.searchModel": "Modello di ricerca",
"modelProvider.selectModel": "Seleziona il tuo modello",
"modelProvider.selector.emptySetting": "Per favore vai alle impostazioni per configurare",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "호출 횟수",
"modelProvider.card.buyQuota": "Buy Quota",
"modelProvider.card.callTimes": "호출 횟수",
"modelProvider.card.modelAPI": "{{modelName}} 모델이 API 키를 사용하고 있습니다.",
"modelProvider.card.modelNotSupported": "{{modelName}} 모델이 설치되지 않았습니다.",
"modelProvider.card.modelSupported": "{{modelName}} 모델이 이 할당량을 사용하고 있습니다.",
"modelProvider.card.onTrial": "트라이얼 중",
"modelProvider.card.paid": "유료",
"modelProvider.card.priorityUse": "우선 사용",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "남은 무료 토큰 사용 가능",
"modelProvider.rerankModel.key": "재랭크 모델",
"modelProvider.rerankModel.tip": "재랭크 모델은 사용자 쿼리와의 의미적 일치를 기반으로 후보 문서 목록을 재배열하여 의미적 순위를 향상시킵니다.",
"modelProvider.resetDate": "{{date}}에서 재설정",
"modelProvider.searchModel": "검색 모델",
"modelProvider.selectModel": "모델 선택",
"modelProvider.selector.emptySetting": "설정으로 이동하여 구성하세요",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "Czasy wywołań",
"modelProvider.card.buyQuota": "Kup limit",
"modelProvider.card.callTimes": "Czasy wywołań",
"modelProvider.card.modelAPI": "Modele {{modelName}} używają klucza API.",
"modelProvider.card.modelNotSupported": "Modele {{modelName}} nie są zainstalowane.",
"modelProvider.card.modelSupported": "{{modelName}} modeli korzysta z tej kwoty.",
"modelProvider.card.onTrial": "Na próbę",
"modelProvider.card.paid": "Płatny",
"modelProvider.card.priorityUse": "Używanie z priorytetem",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "Pozostałe dostępne darmowe tokeny",
"modelProvider.rerankModel.key": "Model ponownego rankingu",
"modelProvider.rerankModel.tip": "Model ponownego rankingu zmieni kolejność listy dokumentów kandydatów na podstawie semantycznego dopasowania z zapytaniem użytkownika, poprawiając wyniki rankingu semantycznego",
"modelProvider.resetDate": "Reset na {{date}}",
"modelProvider.searchModel": "Model wyszukiwania",
"modelProvider.selectModel": "Wybierz swój model",
"modelProvider.selector.emptySetting": "Przejdź do ustawień, aby skonfigurować",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "Chamadas",
"modelProvider.card.buyQuota": "Comprar Quota",
"modelProvider.card.callTimes": "Chamadas",
"modelProvider.card.modelAPI": "Os modelos {{modelName}} estão usando a Chave de API.",
"modelProvider.card.modelNotSupported": "Modelos {{modelName}} não estão instalados.",
"modelProvider.card.modelSupported": "Modelos {{modelName}} estão usando esta cota.",
"modelProvider.card.onTrial": "Em Teste",
"modelProvider.card.paid": "Pago",
"modelProvider.card.priorityUse": "Uso prioritário",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "Tokens gratuitos disponíveis restantes",
"modelProvider.rerankModel.key": "Modelo de Reordenação",
"modelProvider.rerankModel.tip": "O modelo de reordenaenação reorganizará a lista de documentos candidatos com base na correspondência semântica com a consulta do usuário, melhorando os resultados da classificação semântica",
"modelProvider.resetDate": "Redefinir em {{date}}",
"modelProvider.searchModel": "Modelo de pesquisa",
"modelProvider.selectModel": "Selecione seu modelo",
"modelProvider.selector.emptySetting": "Por favor, vá para configurações para configurar",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "Apeluri",
"modelProvider.card.buyQuota": "Cumpără cotă",
"modelProvider.card.callTimes": "Apeluri",
"modelProvider.card.modelAPI": "Modelele {{modelName}} folosesc cheia API.",
"modelProvider.card.modelNotSupported": "Modelele {{modelName}} nu sunt instalate.",
"modelProvider.card.modelSupported": "{{modelName}} modele utilizează această cotă.",
"modelProvider.card.onTrial": "În probă",
"modelProvider.card.paid": "Plătit",
"modelProvider.card.priorityUse": "Utilizare prioritară",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "Jetoane gratuite disponibile rămase",
"modelProvider.rerankModel.key": "Model de reordonare",
"modelProvider.rerankModel.tip": "Modelul de reordonare va reordona lista de documente candidate pe baza potrivirii semantice cu interogarea utilizatorului, îmbunătățind rezultatele clasificării semantice",
"modelProvider.resetDate": "Resetați la {{date}}",
"modelProvider.searchModel": "Model de căutare",
"modelProvider.selectModel": "Selectați modelul dvs.",
"modelProvider.selector.emptySetting": "Vă rugăm să mergeți la setări pentru a configura",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "Количество вызовов",
"modelProvider.card.buyQuota": "Купить квоту",
"modelProvider.card.callTimes": "Количество вызовов",
"modelProvider.card.modelAPI": "{{modelName}} модели используют ключ API.",
"modelProvider.card.modelNotSupported": "Модели {{modelName}} не установлены.",
"modelProvider.card.modelSupported": "Эту квоту используют модели {{modelName}}.",
"modelProvider.card.onTrial": "Пробная версия",
"modelProvider.card.paid": "Платный",
"modelProvider.card.priorityUse": "Приоритетное использование",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "Оставшиеся доступные бесплатные токены",
"modelProvider.rerankModel.key": "Модель повторного ранжирования",
"modelProvider.rerankModel.tip": "Модель повторного ранжирования изменит порядок списка документов-кандидатов на основе семантического соответствия запросу пользователя, улучшая результаты семантического ранжирования",
"modelProvider.resetDate": "Сброс на {{date}}",
"modelProvider.searchModel": "Поиск модели",
"modelProvider.selectModel": "Выберите свою модель",
"modelProvider.selector.emptySetting": "Пожалуйста, перейдите в настройки для настройки",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "Število klicev",
"modelProvider.card.buyQuota": "Kupi kvoto",
"modelProvider.card.callTimes": "Časi klicev",
"modelProvider.card.modelAPI": "{{modelName}} modeli uporabljajo API ključ.",
"modelProvider.card.modelNotSupported": "{{modelName}} modeli niso nameščeni.",
"modelProvider.card.modelSupported": "{{modelName}} modeli uporabljajo to kvoto.",
"modelProvider.card.onTrial": "Na preizkusu",
"modelProvider.card.paid": "Plačano",
"modelProvider.card.priorityUse": "Prednostna uporaba",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "Preostali razpoložljivi brezplačni žetoni",
"modelProvider.rerankModel.key": "Model za prerazvrstitev",
"modelProvider.rerankModel.tip": "Model za prerazvrstitev bo prerazporedil seznam kandidatskih dokumentov na podlagi semantične ujemanja z uporabniško poizvedbo, s čimer se izboljšajo rezultati semantičnega razvrščanja.",
"modelProvider.resetDate": "Ponastavi na {{date}}",
"modelProvider.searchModel": "Model iskanja",
"modelProvider.selectModel": "Izberite svoj model",
"modelProvider.selector.emptySetting": "Prosimo, pojdite v nastavitve za konfiguracijo",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "เวลาโทร",
"modelProvider.card.buyQuota": "ซื้อโควต้า",
"modelProvider.card.callTimes": "เวลาโทร",
"modelProvider.card.modelAPI": "{{modelName}} โมเดลกำลังใช้คีย์ API",
"modelProvider.card.modelNotSupported": "โมเดล {{modelName}} ยังไม่ได้ติดตั้ง",
"modelProvider.card.modelSupported": "โมเดล {{modelName}} กำลังใช้โควต้านี้อยู่",
"modelProvider.card.onTrial": "ทดลองใช้",
"modelProvider.card.paid": "จ่าย",
"modelProvider.card.priorityUse": "ลําดับความสําคัญในการใช้งาน",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "โทเค็นฟรีที่เหลืออยู่",
"modelProvider.rerankModel.key": "จัดอันดับโมเดลใหม่",
"modelProvider.rerankModel.tip": "โมเดล Rerank จะจัดลําดับรายการเอกสารผู้สมัครใหม่ตามการจับคู่ความหมายกับการสืบค้นของผู้ใช้ ซึ่งช่วยปรับปรุงผลลัพธ์ของการจัดอันดับความหมาย",
"modelProvider.resetDate": "รีเซ็ตเมื่อ {{date}}",
"modelProvider.searchModel": "ค้นหารุ่น",
"modelProvider.selectModel": "เลือกรุ่นของคุณ",
"modelProvider.selector.emptySetting": "โปรดไปที่การตั้งค่าเพื่อกําหนดค่า",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "Çağrı Süreleri",
"modelProvider.card.buyQuota": "Kota Satın Al",
"modelProvider.card.callTimes": "Çağrı Süreleri",
"modelProvider.card.modelAPI": "{{modelName}} modelleri API Anahtarını kullanıyor.",
"modelProvider.card.modelNotSupported": "{{modelName}} modelleri yüklü değil.",
"modelProvider.card.modelSupported": "{{modelName}} modelleri bu kotayı kullanıyor.",
"modelProvider.card.onTrial": "Deneme Sürümünde",
"modelProvider.card.paid": "Ücretli",
"modelProvider.card.priorityUse": "Öncelikli Kullan",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "Kalan kullanılabilir ücretsiz tokenler",
"modelProvider.rerankModel.key": "Yeniden Sıralama Modeli",
"modelProvider.rerankModel.tip": "Yeniden sıralama modeli, kullanıcı sorgusuyla anlam eşleştirmesine dayalı olarak aday belge listesini yeniden sıralayacak ve anlam sıralama sonuçlarını iyileştirecektir.",
"modelProvider.resetDate": "{{date}} üzerine sıfırlama",
"modelProvider.searchModel": "Model ara",
"modelProvider.selectModel": "Modelinizi seçin",
"modelProvider.selector.emptySetting": "Lütfen ayarlara gidip yapılandırın",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "Кількість викликів",
"modelProvider.card.buyQuota": "Придбати квоту",
"modelProvider.card.callTimes": "Кількість викликів",
"modelProvider.card.modelAPI": "Моделі {{modelName}} використовують API-ключ.",
"modelProvider.card.modelNotSupported": "Моделі {{modelName}} не встановлені.",
"modelProvider.card.modelSupported": "Моделі {{modelName}} використовують цю квоту.",
"modelProvider.card.onTrial": "У пробному періоді",
"modelProvider.card.paid": "Оплачено",
"modelProvider.card.priorityUse": "Пріоритетне використання",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "Залишилося доступних безкоштовних токенів",
"modelProvider.rerankModel.key": "Модель повторного ранжування",
"modelProvider.rerankModel.tip": "Модель повторного ранжування змінить порядок списку документів-кандидатів на основі семантичної відповідності запиту користувача, покращуючи результати семантичного ранжування.",
"modelProvider.resetDate": "Скинути на {{date}}",
"modelProvider.searchModel": "Пошукова модель",
"modelProvider.selectModel": "Виберіть свою модель",
"modelProvider.selector.emptySetting": "Перейдіть до налаштувань, щоб налаштувати",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "Số lần gọi",
"modelProvider.card.buyQuota": "Mua Quota",
"modelProvider.card.callTimes": "Số lần gọi",
"modelProvider.card.modelAPI": "Các mô hình {{modelName}} đang sử dụng Khóa API.",
"modelProvider.card.modelNotSupported": "Các mô hình {{modelName}} chưa được cài đặt.",
"modelProvider.card.modelSupported": "{{modelName}} mô hình đang sử dụng hạn mức này.",
"modelProvider.card.onTrial": "Thử nghiệm",
"modelProvider.card.paid": "Đã thanh toán",
"modelProvider.card.priorityUse": "Ưu tiên sử dụng",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "Số lượng mã thông báo miễn phí còn lại",
"modelProvider.rerankModel.key": "Mô hình Sắp xếp lại",
"modelProvider.rerankModel.tip": "Mô hình sắp xếp lại sẽ sắp xếp lại danh sách tài liệu ứng cử viên dựa trên sự phù hợp ngữ nghĩa với truy vấn của người dùng, cải thiện kết quả của việc xếp hạng ngữ nghĩa",
"modelProvider.resetDate": "Đặt lại vào {{date}}",
"modelProvider.searchModel": "Mô hình tìm kiếm",
"modelProvider.selectModel": "Chọn mô hình của bạn",
"modelProvider.selector.emptySetting": "Vui lòng vào cài đặt để cấu hình",
+4
View File
@@ -339,6 +339,9 @@
"modelProvider.callTimes": "呼叫次數",
"modelProvider.card.buyQuota": "購買額度",
"modelProvider.card.callTimes": "呼叫次數",
"modelProvider.card.modelAPI": "{{modelName}} 模型正在使用 API 金鑰。",
"modelProvider.card.modelNotSupported": "{{modelName}} 模型未安裝。",
"modelProvider.card.modelSupported": "{{modelName}} 模型正在使用這個配額。",
"modelProvider.card.onTrial": "試用中",
"modelProvider.card.paid": "已購買",
"modelProvider.card.priorityUse": "優先使用",
@@ -394,6 +397,7 @@
"modelProvider.quotaTip": "剩餘免費額度",
"modelProvider.rerankModel.key": "Rerank 模型",
"modelProvider.rerankModel.tip": "重排序模型將根據候選文件列表與使用者問題語義匹配度進行重新排序,從而改進語義排序的結果",
"modelProvider.resetDate": "在 {{date}} 重置",
"modelProvider.searchModel": "搜尋模型",
"modelProvider.selectModel": "選擇您的模型",
"modelProvider.selector.emptySetting": "請前往設定進行配置",