Merge upstream/release/e-1.11.2 into 1.11.4

# Conflicts:
#	.github/workflows/tool-test-sdks.yaml
#	.github/workflows/translate-i18n-claude.yml
#	api/core/rag/datasource/retrieval_service.py
#	web/app/components/app/create-app-dialog/app-list/index.tsx
#	web/app/components/header/account-setting/members-page/operation/index.tsx
#	web/i18n-config/server.ts
#	web/package.json
#	web/pnpm-lock.yaml
This commit is contained in:
npc0-hue
2026-01-16 15:53:11 +08:00
15 changed files with 743 additions and 1194 deletions
+1 -1
View File
@@ -90,7 +90,7 @@ jobs:
uses: actions/setup-node@v6
if: steps.changed-files.outputs.any_changed == 'true'
with:
node-version: 22
node-version: 24
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml
+7 -6
View File
@@ -16,23 +16,24 @@ jobs:
name: unit test for Node.js SDK
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16, 18, 20, 22]
defaults:
run:
working-directory: sdks/nodejs-client
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
persist-credentials: false
<<<<<<< HEAD
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
=======
- name: Use Node.js
uses: actions/setup-node@v6
>>>>>>> 328897f81c (build: require node 24.13.0 (#30945))
with:
node-version: ${{ matrix.node-version }}
node-version: 24
cache: ''
cache-dependency-path: 'pnpm-lock.yaml'
+30 -19
View File
@@ -1,10 +1,12 @@
name: Translate i18n Files with Claude Code
# Note: claude-code-action doesn't support push events directly.
# Push events are handled by trigger-i18n-sync.yml which sends repository_dispatch.
# See: https://github.com/langgenius/dify/issues/30743
on:
push:
branches: [main]
paths:
- 'web/i18n/en-US/*.json'
repository_dispatch:
types: [i18n-sync]
workflow_dispatch:
inputs:
files:
@@ -55,7 +57,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
node-version: 24
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml
@@ -87,26 +89,35 @@ jobs:
echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT
fi
fi
else
# Push trigger - detect changed files from the push
BEFORE_SHA="${{ github.event.before }}"
# Handle edge case: first push or force push may have null/zero SHA
if [ -z "$BEFORE_SHA" ] || [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
# Fallback to comparing with parent commit
BEFORE_SHA="HEAD~1"
elif [ "${{ github.event_name }}" == "repository_dispatch" ]; then
# Triggered by push via trigger-i18n-sync.yml workflow
# Validate required payload fields
if [ -z "${{ github.event.client_payload.changed_files }}" ]; then
echo "Error: repository_dispatch payload missing required 'changed_files' field" >&2
exit 1
fi
changed=$(git diff --name-only "$BEFORE_SHA" ${{ github.sha }} -- 'web/i18n/en-US/*.json' 2>/dev/null | xargs -n1 basename 2>/dev/null | sed 's/.json$//' | tr '\n' ' ' || echo "")
echo "CHANGED_FILES=$changed" >> $GITHUB_OUTPUT
echo "CHANGED_FILES=${{ github.event.client_payload.changed_files }}" >> $GITHUB_OUTPUT
echo "TARGET_LANGS=" >> $GITHUB_OUTPUT
echo "SYNC_MODE=incremental" >> $GITHUB_OUTPUT
echo "SYNC_MODE=${{ github.event.client_payload.sync_mode || 'incremental' }}" >> $GITHUB_OUTPUT
# Generate detailed diff for the push
git diff "$BEFORE_SHA"..${{ github.sha }} -- 'web/i18n/en-US/*.json' > /tmp/i18n-diff.txt 2>/dev/null || echo "" > /tmp/i18n-diff.txt
if [ -s /tmp/i18n-diff.txt ]; then
echo "DIFF_AVAILABLE=true" >> $GITHUB_OUTPUT
# Decode the base64-encoded diff from the trigger workflow
if [ -n "${{ github.event.client_payload.diff_base64 }}" ]; then
if ! echo "${{ github.event.client_payload.diff_base64 }}" | base64 -d > /tmp/i18n-diff.txt 2>&1; then
echo "Warning: Failed to decode base64 diff payload" >&2
echo "" > /tmp/i18n-diff.txt
echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT
elif [ -s /tmp/i18n-diff.txt ]; then
echo "DIFF_AVAILABLE=true" >> $GITHUB_OUTPUT
else
echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT
fi
else
echo "" > /tmp/i18n-diff.txt
echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT
fi
else
echo "Unsupported event type: ${{ github.event_name }}"
exit 1
fi
# Truncate diff if too large (keep first 50KB)
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml
+24 -50
View File
@@ -1,5 +1,4 @@
import concurrent.futures
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any
@@ -14,7 +13,7 @@ from core.model_runtime.entities.model_entities import ModelType
from core.rag.data_post_processor.data_post_processor import DataPostProcessor
from core.rag.datasource.keyword.keyword_factory import Keyword
from core.rag.datasource.vdb.vector_factory import Vector
from core.rag.embedding.retrieval import RetrievalChildChunk, RetrievalSegments
from core.rag.embedding.retrieval import RetrievalSegments
from core.rag.entities.metadata_entities import MetadataCondition
from core.rag.index_processor.constant.doc_type import DocType
from core.rag.index_processor.constant.index_type import IndexStructureType
@@ -37,8 +36,6 @@ default_retrieval_model = {
"score_threshold_enabled": False,
}
logger = logging.getLogger(__name__)
class RetrievalService:
# Cache precompiled regular expressions to avoid repeated compilation
@@ -109,12 +106,7 @@ class RetrievalService:
)
)
if futures:
for future in concurrent.futures.as_completed(futures, timeout=3600):
if exceptions:
for f in futures:
f.cancel()
break
concurrent.futures.wait(futures, timeout=3600, return_when=concurrent.futures.ALL_COMPLETED)
if exceptions:
raise ValueError(";\n".join(exceptions))
@@ -218,7 +210,6 @@ class RetrievalService:
)
all_documents.extend(documents)
except Exception as e:
logger.error(e, exc_info=True)
exceptions.append(str(e))
@classmethod
@@ -312,7 +303,6 @@ class RetrievalService:
else:
all_documents.extend(documents)
except Exception as e:
logger.error(e, exc_info=True)
exceptions.append(str(e))
@classmethod
@@ -361,7 +351,6 @@ class RetrievalService:
else:
all_documents.extend(documents)
except Exception as e:
logger.error(e, exc_info=True)
exceptions.append(str(e))
@staticmethod
@@ -427,12 +416,12 @@ class RetrievalService:
child_index_node_ids = [i for i in child_index_node_ids if i]
index_node_ids = [i for i in index_node_ids if i]
segment_ids: list[str] = []
segment_ids = []
index_node_segments: list[DocumentSegment] = []
segments: list[DocumentSegment] = []
attachment_map: dict[str, list[dict[str, Any]]] = {}
child_chunk_map: dict[str, list[ChildChunk]] = {}
doc_segment_map: dict[str, list[str]] = {}
attachment_map = {}
child_chunk_map: dict[Any, Any] = {}
doc_segment_map = {}
with session_factory.create_session() as session:
attachments = cls.get_segment_attachment_infos(image_doc_ids, session)
@@ -443,7 +432,7 @@ class RetrievalService:
attachment_map[attachment["segment_id"]].append(attachment["attachment_info"])
else:
attachment_map[attachment["segment_id"]] = [attachment["attachment_info"]]
if attachment["segment_id"] in doc_segment_map:
if attachment["attachment_id"] in doc_segment_map:
doc_segment_map[attachment["segment_id"]].append(attachment["attachment_id"])
else:
doc_segment_map[attachment["segment_id"]] = [attachment["attachment_id"]]
@@ -513,7 +502,7 @@ class RetrievalService:
"child_chunks": child_chunk_details,
}
segment_child_map[segment.id] = map_detail
record: dict[str, Any] = {
record = {
"segment": segment,
}
records.append(record)
@@ -521,13 +510,13 @@ class RetrievalService:
if segment.id not in include_segment_ids:
include_segment_ids.add(segment.id)
max_score = 0.0
segment_document = doc_to_document_map.get(segment.index_node_id)
if segment_document:
max_score = max(max_score, segment_document.metadata.get("score", 0.0))
document = doc_to_document_map.get(segment.index_node_id)
if document:
max_score = max(max_score, document.metadata.get("score", 0.0))
for attachment_info in attachment_infos:
file_doc = doc_to_document_map.get(attachment_info["id"])
if file_doc:
max_score = max(max_score, file_doc.metadata.get("score", 0.0))
file_document = doc_to_document_map.get(attachment_info["id"])
if file_document:
max_score = max(max_score, file_document.metadata.get("score", 0.0))
record = {
"segment": segment,
"score": max_score,
@@ -542,26 +531,18 @@ class RetrievalService:
if record["segment"].id in attachment_map:
record["files"] = attachment_map[record["segment"].id] # type: ignore[assignment]
result: list[RetrievalSegments] = []
result = []
for record in records:
# Extract segment
segment = record["segment"]
# Extract child_chunks, ensuring it's a list or None
raw_child_chunks = record.get("child_chunks")
child_chunks_list: list[RetrievalChildChunk] | None = None
if isinstance(raw_child_chunks, list):
# Sort by score descending
sorted_chunks = sorted(raw_child_chunks, key=lambda x: x.get("score", 0.0), reverse=True)
child_chunks_list = [
RetrievalChildChunk(
id=chunk["id"],
content=chunk["content"],
score=chunk.get("score", 0.0),
position=chunk["position"],
)
for chunk in sorted_chunks
]
child_chunks = record.get("child_chunks")
if not isinstance(child_chunks, list):
child_chunks = None
if child_chunks:
child_chunks = sorted(child_chunks, key=lambda x: x.get("score", 0.0), reverse=True)
# Extract files, ensuring it's a list or None
files = record.get("files")
@@ -578,11 +559,11 @@ class RetrievalService:
# Create RetrievalSegments object
retrieval_segment = RetrievalSegments(
segment=segment, child_chunks=child_chunks_list, score=score, files=files
segment=segment, child_chunks=child_chunks, score=score, files=files
)
result.append(retrieval_segment)
return sorted(result, key=lambda x: x.score if x.score is not None else 0.0, reverse=True)
return sorted(result, key=lambda x: x.score, reverse=True)
except Exception as e:
db.session.rollback()
raise e
@@ -674,14 +655,7 @@ class RetrievalService:
document_ids_filter=document_ids_filter,
)
)
# Use as_completed for early error propagation - cancel remaining futures on first error
if futures:
for future in concurrent.futures.as_completed(futures, timeout=300):
if future.exception():
# Cancel remaining futures to avoid unnecessary waiting
for f in futures:
f.cancel()
break
concurrent.futures.wait(futures, timeout=300, return_when=concurrent.futures.ALL_COMPLETED)
if exceptions:
raise ValueError(";\n".join(exceptions))
+2
View File
@@ -6,6 +6,7 @@ from .create_site_record_when_app_created import handle as handle_create_site_re
from .delete_tool_parameters_cache_when_sync_draft_workflow import (
handle as handle_delete_tool_parameters_cache_when_sync_draft_workflow,
)
from .queue_credential_sync_when_tenant_created import handle as handle_queue_credential_sync_when_tenant_created
from .sync_plugin_trigger_when_app_created import handle as handle_sync_plugin_trigger_when_app_created
from .sync_webhook_when_app_created import handle as handle_sync_webhook_when_app_created
from .sync_workflow_schedule_when_app_published import handle as handle_sync_workflow_schedule_when_app_published
@@ -33,6 +34,7 @@ __all__ = [
"handle_create_installed_app_when_app_created",
"handle_create_site_record_when_app_created",
"handle_delete_tool_parameters_cache_when_sync_draft_workflow",
"handle_queue_credential_sync_when_tenant_created",
"handle_sync_plugin_trigger_when_app_created",
"handle_sync_webhook_when_app_created",
"handle_sync_workflow_schedule_when_app_published",
@@ -0,0 +1,19 @@
from configs import dify_config
from events.tenant_event import tenant_was_created
from services.enterprise.workspace_sync import WorkspaceSyncService
@tenant_was_created.connect
def handle(sender, **kwargs):
"""Queue credential sync when a tenant/workspace is created."""
# Only queue sync tasks if plugin manager (enterprise feature) is enabled
if not dify_config.ENTERPRISE_ENABLED:
return
tenant = sender
# Determine source from kwargs if available, otherwise use generic
source = kwargs.get("source", "tenant_created")
# Queue credential sync task to Redis for enterprise backend to process
WorkspaceSyncService.queue_credential_sync(tenant.id, source=source)
+58
View File
@@ -0,0 +1,58 @@
import json
import logging
import uuid
from datetime import UTC, datetime
from redis import RedisError
from extensions.ext_redis import redis_client
logger = logging.getLogger(__name__)
WORKSPACE_SYNC_QUEUE = "enterprise:workspace:sync:queue"
WORKSPACE_SYNC_PROCESSING = "enterprise:workspace:sync:processing"
class WorkspaceSyncService:
"""Service to publish workspace sync tasks to Redis queue for enterprise backend consumption"""
@staticmethod
def queue_credential_sync(workspace_id: str, *, source: str) -> bool:
"""
Queue a credential sync task for a newly created workspace.
This publishes a task to Redis that will be consumed by the enterprise backend
worker to sync credentials with the plugin-manager.
Args:
workspace_id: The workspace/tenant ID to sync credentials for
source: Source of the sync request (for debugging/tracking)
Returns:
bool: True if task was queued successfully, False otherwise
"""
try:
task = {
"task_id": str(uuid.uuid4()),
"workspace_id": workspace_id,
"retry_count": 0,
"created_at": datetime.now(UTC).isoformat(),
"source": source,
}
# Push to Redis list (queue) - LPUSH adds to the head, worker consumes from tail with RPOP
redis_client.lpush(WORKSPACE_SYNC_QUEUE, json.dumps(task))
logger.info(
"Queued credential sync task for workspace %s, task_id: %s, source: %s",
workspace_id,
task["task_id"],
source,
)
return True
except (RedisError, TypeError) as e:
logger.error("Failed to queue credential sync for workspace %s: %s", workspace_id, str(e), exc_info=True)
# Don't raise - we don't want to fail workspace creation if queueing fails
# The scheduled task will catch it later
return False
+1 -1
View File
@@ -1,5 +1,5 @@
# base image
FROM node:22-alpine3.21 AS base
FROM node:24-alpine AS base
LABEL maintainer="takatost@gmail.com"
# if you located in China, you can use aliyun mirror to speed up
+2 -2
View File
@@ -8,8 +8,8 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next
Before starting the web frontend service, please make sure the following environment is ready.
- [Node.js](https://nodejs.org) >= v22.11.x
- [pnpm](https://pnpm.io) v10.x
- [Node.js](https://nodejs.org)
- [pnpm](https://pnpm.io)
First, install the dependencies:
@@ -18,6 +18,7 @@ import CreateAppModal from '@/app/components/explore/create-app-modal'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
import { DSLImportMode } from '@/models/app'
import { importDSL } from '@/service/apps'
import { fetchAppDetail } from '@/service/explore'
@@ -60,7 +61,10 @@ const Apps = ({
}
const [currentType, setCurrentType] = useState<AppModeEnum[]>([])
const [currCategory, setCurrCategory] = useState<AppCategories | string>(allCategoriesEn)
const [currCategory, setCurrCategory] = useTabSearchParams({
defaultTab: allCategoriesEn,
disableSearchParams: true,
})
const {
data,
@@ -139,7 +143,7 @@ const Apps = ({
setIsShowCreateModal(false)
Toast.notify({
type: 'success',
message: t('newApp.appCreated', { ns: 'app' }),
message: t('app.newApp.appCreated'),
})
if (onSuccess)
onSuccess()
@@ -149,7 +153,7 @@ const Apps = ({
getRedirection(isCurrentWorkspaceEditor, { id: app.app_id!, mode }, push)
}
catch {
Toast.notify({ type: 'error', message: t('newApp.appCreateFailed', { ns: 'app' }) })
Toast.notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
}
}
@@ -165,7 +169,7 @@ const Apps = ({
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-divider-burn py-3">
<div className="min-w-[180px] pl-5">
<span className="title-xl-semi-bold text-text-primary">{t('newApp.startFromTemplate', { ns: 'app' })}</span>
<span className="title-xl-semi-bold text-text-primary">{t('app.newApp.startFromTemplate')}</span>
</div>
<div className="flex max-w-[548px] flex-1 items-center rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-1.5 shadow-md">
<AppTypeSelector value={currentType} onChange={setCurrentType} />
@@ -176,7 +180,7 @@ const Apps = ({
showClearIcon
wrapperClassName="w-full flex-1"
className="bg-transparent hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent focus:shadow-none"
placeholder={t('newAppFromTemplate.searchAllTemplate', { ns: 'app' }) as string}
placeholder={t('app.newAppFromTemplate.searchAllTemplate') as string}
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
@@ -195,7 +199,7 @@ const Apps = ({
<>
<div className="pb-1 pt-4">
{searchKeywords
? <p className="title-md-semi-bold text-text-tertiary">{searchFilteredList.length > 1 ? t('newApp.foundResults', { ns: 'app', count: searchFilteredList.length }) : t('newApp.foundResult', { ns: 'app', count: searchFilteredList.length })}</p>
? <p className="title-md-semi-bold text-text-tertiary">{searchFilteredList.length > 1 ? t('app.newApp.foundResults', { count: searchFilteredList.length }) : t('app.newApp.foundResult', { count: searchFilteredList.length })}</p>
: (
<div className="flex h-[22px] items-center">
<AppCategoryLabel category={currCategory as AppCategories} className="title-md-semi-bold text-text-primary" />
@@ -250,8 +254,8 @@ function NoTemplateFound() {
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-components-card-bg shadow-lg">
<RiRobot2Line className="h-5 w-5 text-text-tertiary" />
</div>
<p className="title-md-semi-bold text-text-primary">{t('newApp.noTemplateFound', { ns: 'app' })}</p>
<p className="system-sm-regular text-text-tertiary">{t('newApp.noTemplateFoundTip', { ns: 'app' })}</p>
<p className="title-md-semi-bold text-text-primary">{t('app.newApp.noTemplateFound')}</p>
<p className="system-sm-regular text-text-tertiary">{t('app.newApp.noTemplateFoundTip')}</p>
</div>
)
}
@@ -20,15 +20,6 @@ type IOperationProps = {
onOperate: () => void
}
const roleI18nKeyMap = {
admin: { label: 'members.admin', tip: 'members.adminTip' },
editor: { label: 'members.editor', tip: 'members.editorTip' },
normal: { label: 'members.normal', tip: 'members.normalTip' },
dataset_operator: { label: 'members.datasetOperator', tip: 'members.datasetOperatorTip' },
} as const
type OperationRoleKey = keyof typeof roleI18nKeyMap
const Operation = ({
member,
operatorRole,
@@ -38,37 +29,38 @@ const Operation = ({
const { t } = useTranslation()
const { datasetOperatorEnabled } = useProviderContext()
const RoleMap = {
owner: t('members.owner', { ns: 'common' }),
admin: t('members.admin', { ns: 'common' }),
editor: t('members.editor', { ns: 'common' }),
normal: t('members.normal', { ns: 'common' }),
dataset_operator: t('members.datasetOperator', { ns: 'common' }),
owner: t('common.members.owner'),
admin: t('common.members.admin'),
editor: t('common.members.editor'),
normal: t('common.members.normal'),
dataset_operator: t('common.members.datasetOperator'),
}
const roleList = useMemo((): OperationRoleKey[] => {
const roleList = useMemo(() => {
if (operatorRole === 'owner') {
return [
'admin',
'editor',
'normal',
...(datasetOperatorEnabled ? ['dataset_operator'] as const : []),
...(datasetOperatorEnabled ? ['dataset_operator'] : []),
]
}
if (operatorRole === 'admin') {
return [
'editor',
'normal',
...(datasetOperatorEnabled ? ['dataset_operator'] as const : []),
...(datasetOperatorEnabled ? ['dataset_operator'] : []),
]
}
return []
}, [operatorRole, datasetOperatorEnabled])
const { notify } = useContext(ToastContext)
const toHump = (name: string) => name.replace(/_(\w)/g, (all, letter) => letter.toUpperCase())
const handleDeleteMemberOrCancelInvitation = async () => {
setOpen(false)
try {
await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` })
onOperate()
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
}
catch {
@@ -82,7 +74,7 @@ const Operation = ({
body: { role },
})
onOperate()
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
}
catch {
@@ -114,8 +106,8 @@ const Operation = ({
: <div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
}
<div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t(roleI18nKeyMap[role].label, { ns: 'common' })}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t(roleI18nKeyMap[role].tip, { ns: 'common' })}</div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t(`common.members.${toHump(role)}` as any)}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t(`common.members.${toHump(role)}Tip` as any)}</div>
</div>
</div>
))
@@ -125,8 +117,8 @@ const Operation = ({
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={handleDeleteMemberOrCancelInvitation}>
<div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
<div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('members.removeFromTeam', { ns: 'common' })}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('members.removeFromTeamTip', { ns: 'common' })}</div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('common.members.removeFromTeam')}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('common.members.removeFromTeamTip')}</div>
</div>
</div>
</div>
+14 -51
View File
@@ -1,60 +1,37 @@
import type { i18n as I18nInstance, Resource, ResourceLanguage } from 'i18next'
import type { Locale } from '.'
import type { NamespaceCamelCase, NamespaceKebabCase } from './resources'
import type { Namespace } from './i18next-config'
import { match } from '@formatjs/intl-localematcher'
import { kebabCase } from 'es-toolkit/compat'
import { camelCase } from 'es-toolkit/string'
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import Negotiator from 'negotiator'
import { cookies, headers } from 'next/headers'
import { cache } from 'react'
import { initReactI18next } from 'react-i18next/initReactI18next'
import { serverOnlyContext } from '@/utils/server-only-context'
import { i18n } from '.'
import { namespacesKebabCase } from './resources'
import { getInitOptions } from './settings'
const [getLocaleCache, setLocaleCache] = serverOnlyContext<Locale | null>(null)
const [getI18nInstance, setI18nInstance] = serverOnlyContext<I18nInstance | null>(null)
const getOrCreateI18next = async (lng: Locale) => {
let instance = getI18nInstance()
if (instance)
return instance
instance = createInstance()
await instance
// https://locize.com/blog/next-13-app-dir-i18n/
const initI18next = async (lng: Locale, ns: Namespace) => {
const i18nInstance = createInstance()
await i18nInstance
.use(initReactI18next)
.use(resourcesToBackend((language: Locale, namespace: NamespaceCamelCase | NamespaceKebabCase) => {
const fileNamespace = kebabCase(namespace) as NamespaceKebabCase
return import(`../i18n/${language}/${fileNamespace}.json`)
}))
.use(resourcesToBackend((language: Locale, namespace: Namespace) => import(`../i18n/${language}/${namespace}.ts`)))
.init({
...getInitOptions(),
lng,
lng: lng === 'zh-Hans' ? 'zh-Hans' : lng,
ns,
fallbackLng: 'en-US',
})
setI18nInstance(instance)
return instance
return i18nInstance
}
export async function getTranslation(lng: Locale, ns?: NamespaceCamelCase) {
const i18nextInstance = await getOrCreateI18next(lng)
if (ns && !i18nextInstance.hasLoadedNamespace(ns))
await i18nextInstance.loadNamespaces(ns)
export async function getTranslation(lng: Locale, ns: Namespace, options: Record<string, any> = {}) {
const i18nextInstance = await initI18next(lng, ns)
return {
t: i18nextInstance.getFixedT(lng, ns),
// @ts-expect-error types mismatch
t: i18nextInstance.getFixedT(lng, ns, options.keyPrefix),
i18n: i18nextInstance,
}
}
export const getLocaleOnServer = async (): Promise<Locale> => {
const cached = getLocaleCache()
if (cached)
return cached
const locales: string[] = i18n.locales
let languages: string[] | undefined
@@ -76,19 +53,5 @@ export const getLocaleOnServer = async (): Promise<Locale> => {
// match locale
const matchedLocale = match(languages, locales, i18n.defaultLocale) as Locale
setLocaleCache(matchedLocale)
return matchedLocale
}
export const getResources = cache(async (lng: Locale): Promise<Resource> => {
const messages = {} as ResourceLanguage
await Promise.all(
(namespacesKebabCase).map(async (ns) => {
const mod = await import(`../i18n/${lng}/${ns}.json`)
messages[camelCase(ns)] = mod.default
}),
)
return { [lng]: messages }
})
+22 -20
View File
@@ -10,9 +10,6 @@
"default": "./i18n-config/lib.client.ts"
}
},
"engines": {
"node": ">=v22.11.0"
},
"browserslist": [
"last 1 Chrome version",
"last 1 Firefox version",
@@ -37,14 +34,14 @@
"type-check": "tsc --noEmit",
"type-check:tsgo": "tsgo --noEmit",
"prepare": "cd ../ && node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || husky ./web/.husky",
"gen-icons": "node ./scripts/gen-icons.mjs && eslint --fix app/components/base/icons/src/",
"gen-icons": "node ./app/components/base/icons/script.mjs",
"uglify-embed": "node ./bin/uglify-embed",
"i18n:check": "tsx ./scripts/check-i18n.js",
"check-i18n": "tsx ./i18n-config/check-i18n.js",
"auto-gen-i18n": "tsx ./i18n-config/auto-gen-i18n.js",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest --watch",
"analyze-component": "node ./scripts/analyze-component.js",
"refactor-component": "node ./scripts/refactor-component.js",
"analyze-component": "node testing/analyze-component.js",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"preinstall": "npx only-allow pnpm",
@@ -52,13 +49,14 @@
"knip": "knip"
},
"dependencies": {
"@amplitude/analytics-browser": "^2.33.1",
"@amplitude/analytics-browser": "^2.31.3",
"@amplitude/plugin-session-replay-browser": "^1.23.6",
"@emoji-mart/data": "^1.2.1",
"@floating-ui/react": "^0.26.28",
"@formatjs/intl-localematcher": "^0.5.10",
"@headlessui/react": "2.2.1",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^5.2.2",
"@lexical/code": "^0.38.2",
"@lexical/link": "^0.38.2",
"@lexical/list": "^0.38.2",
@@ -75,6 +73,7 @@
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-form": "^1.23.7",
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-query-devtools": "^5.90.2",
"abcjs": "^6.5.2",
"ahooks": "^3.9.5",
"class-variance-authority": "^0.7.1",
@@ -93,7 +92,7 @@
"fast-deep-equal": "^3.1.3",
"html-entities": "^2.6.0",
"html-to-image": "1.11.13",
"i18next": "^25.7.3",
"i18next": "^23.16.8",
"i18next-resources-to-backend": "^1.2.1",
"immer": "^11.1.0",
"jotai": "^2.16.1",
@@ -116,13 +115,14 @@
"nuqs": "^2.8.6",
"pinyin-pro": "^3.27.0",
"qrcode.react": "^4.2.0",
"qs": "^6.14.1",
"qs": "^6.14.0",
"react": "19.2.3",
"react-18-input-autosize": "^3.0.0",
"react-dom": "19.2.3",
"react-easy-crop": "^5.5.3",
"react-hook-form": "^7.65.0",
"react-hotkeys-hook": "^4.6.2",
"react-i18next": "^16.5.0",
"react-i18next": "^15.7.4",
"react-markdown": "^9.1.0",
"react-multi-email": "^1.0.25",
"react-papaparse": "^4.4.0",
@@ -138,11 +138,11 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"scheduler": "^0.27.0",
"scheduler": "^0.26.0",
"semver": "^7.7.3",
"sharp": "^0.33.5",
"sortablejs": "^1.15.6",
"string-ts": "^2.3.1",
"swr": "^2.3.6",
"tailwind-merge": "^2.6.0",
"tldts": "^7.0.17",
"use-context-selector": "^2.0.0",
@@ -169,10 +169,6 @@
"@storybook/addon-themes": "9.1.13",
"@storybook/nextjs": "9.1.13",
"@storybook/react": "9.1.13",
"@tanstack/eslint-plugin-query": "^5.91.2",
"@tanstack/react-devtools": "^0.9.0",
"@tanstack/react-form-devtools": "^0.2.9",
"@tanstack/react-query-devtools": "^5.90.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
@@ -196,6 +192,7 @@
"@vitest/coverage-v8": "4.0.16",
"autoprefixer": "^10.4.21",
"babel-loader": "^10.0.0",
"bing-translate-api": "^4.1.0",
"code-inspector-plugin": "1.2.9",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
@@ -205,10 +202,13 @@
"eslint-plugin-storybook": "^10.1.10",
"eslint-plugin-tailwindcss": "^3.18.2",
"husky": "^9.1.7",
"istanbul-lib-coverage": "^3.2.2",
"jsdom": "^27.3.0",
"jsdom-testing-mocks": "^1.16.0",
"knip": "^5.78.0",
"knip": "^5.66.1",
"lint-staged": "^15.5.2",
"lodash": "^4.17.21",
"magicast": "^0.3.5",
"nock": "^14.0.10",
"postcss": "^8.5.6",
"react-scan": "^0.4.3",
@@ -220,7 +220,8 @@
"uglify-js": "^3.19.3",
"vite": "^7.3.0",
"vite-tsconfig-paths": "^6.0.3",
"vitest": "^4.0.16"
"vitest": "^4.0.16",
"vitest-localstorage-mock": "^0.1.2"
},
"pnpm": {
"overrides": {
@@ -237,7 +238,8 @@
"brace-expansion@<2.0.2": "2.0.2",
"devalue@<5.3.2": "5.3.2",
"es-iterator-helpers": "npm:@nolyfill/es-iterator-helpers@^1",
"esbuild@<0.25.0": "0.25.0",
"esbuild@<0.27.2": "0.27.2",
"glob@>=10.2.0,<10.5.0": "11.1.0",
"hasown": "npm:@nolyfill/hasown@^1",
"is-arguments": "npm:@nolyfill/is-arguments@^1",
"is-core-module": "npm:@nolyfill/is-core-module@^1",
+535 -1012
View File
File diff suppressed because it is too large Load Diff