mirror of
https://github.com/langgenius/dify.git
synced 2026-07-09 14:48:12 +08:00
Compare commits
1 Commits
deploy/dev
...
fix/child-
| Author | SHA1 | Date | |
|---|---|---|---|
| 44ef6f2c7c |
@ -1,6 +1,6 @@
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Iterator, Sequence
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
@ -42,14 +42,13 @@ from controllers.console.wraps import (
|
||||
from core.ops.ops_trace_manager import OpsTraceManager
|
||||
from core.rag.entities import PreProcessingRule, Rule, Segmentation
|
||||
from core.rag.retrieval.retrieval_methods import RetrievalMethod
|
||||
from core.rbac import RBACResourceWhitelistScope
|
||||
from core.trigger.constants import TRIGGER_NODE_TYPES
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from libs.helper import build_icon_url, dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from models import Account, App, DatasetPermissionEnum, TenantAccountJoin, Workflow
|
||||
from models import Account, App, DatasetPermissionEnum, Workflow
|
||||
from models.model import IconType
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.app_service import AppListParams, AppListSortBy, AppService, CreateAppParams, StarredAppListParams
|
||||
@ -78,52 +77,6 @@ _logger = logging.getLogger(__name__)
|
||||
AppListMode = Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"]
|
||||
DEFAULT_APP_LIST_MODE: AppListMode = "all"
|
||||
APP_LIST_QUERY_ARRAY_FIELDS = ("tag_ids", "creator_ids")
|
||||
APP_RBAC_ACCOUNT_POLICY_BATCH_SIZE = 500
|
||||
APP_RBAC_DEFAULT_ACCESS_POLICY_ID = "default"
|
||||
|
||||
|
||||
def _iter_tenant_member_account_id_batches(tenant_id: str, batch_size: int) -> Iterator[list[str]]:
|
||||
"""Yield workspace member account ids in bounded batches for RBAC bulk writes."""
|
||||
offset = 0
|
||||
while True:
|
||||
stmt = (
|
||||
select(TenantAccountJoin.account_id)
|
||||
.where(TenantAccountJoin.tenant_id == tenant_id)
|
||||
.order_by(TenantAccountJoin.id)
|
||||
.offset(offset)
|
||||
.limit(batch_size)
|
||||
)
|
||||
|
||||
account_ids = list(db.session.scalars(stmt).all())
|
||||
if not account_ids:
|
||||
break
|
||||
|
||||
yield account_ids
|
||||
offset += batch_size
|
||||
|
||||
|
||||
def _initialize_created_app_rbac_access(tenant_id: str, account_id: str, app_id: str) -> None:
|
||||
"""Initialize a newly created app's RBAC access for all current workspace members."""
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
return
|
||||
|
||||
enterprise_rbac_service.RBACService.AppAccess.replace_whitelist(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
app_id=app_id,
|
||||
payload=enterprise_rbac_service.ReplaceMemberBindings(scope=RBACResourceWhitelistScope.ALL),
|
||||
)
|
||||
|
||||
for account_ids in _iter_tenant_member_account_id_batches(tenant_id, APP_RBAC_ACCOUNT_POLICY_BATCH_SIZE):
|
||||
enterprise_rbac_service.RBACService.AppAccess.replace_user_access_policies(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
app_id=app_id,
|
||||
payload=enterprise_rbac_service.ReplaceUserAccessPolicies(
|
||||
access_policy_ids=[APP_RBAC_DEFAULT_ACCESS_POLICY_ID],
|
||||
account_ids=account_ids,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AppListBaseQuery(BaseModel):
|
||||
@ -692,7 +645,6 @@ class AppListApi(Resource):
|
||||
|
||||
app_service = AppService()
|
||||
app = app_service.create_app(current_tenant_id, params, current_user, session=db.session())
|
||||
_initialize_created_app_rbac_access(str(current_tenant_id), current_user.id, str(app.id))
|
||||
permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get(
|
||||
str(current_tenant_id),
|
||||
current_user.id,
|
||||
|
||||
@ -69,8 +69,7 @@ def handle_user_connect(sid, data):
|
||||
if not workflow_id:
|
||||
return {"msg": "workflow_id is required"}, 400
|
||||
|
||||
with sio.app.app_context():
|
||||
result = collaboration_service.authorize_and_join_workflow_room(workflow_id, sid, session=db.session())
|
||||
result = collaboration_service.authorize_and_join_workflow_room(workflow_id, sid, session=db.session())
|
||||
if not result:
|
||||
return {"msg": "unauthorized"}, 401
|
||||
|
||||
|
||||
@ -630,9 +630,9 @@ class RBACAppUserAccessPolicyAssignmentApi(Resource):
|
||||
svc.RBACService.AppAccess.replace_user_access_policies(
|
||||
tenant_id,
|
||||
account_id,
|
||||
app_id,
|
||||
str(app_id),
|
||||
str(target_account_id),
|
||||
payload,
|
||||
target_account_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -255,7 +255,6 @@ class ResourceUserAccessPoliciesResponse(_RBACModel):
|
||||
|
||||
class ReplaceUserAccessPolicies(_RBACModel):
|
||||
access_policy_ids: list[str] = Field(default_factory=list)
|
||||
account_ids: list[str] = Field([])
|
||||
|
||||
@field_validator("access_policy_ids", mode="before")
|
||||
@classmethod
|
||||
@ -1127,17 +1126,16 @@ class RBACService:
|
||||
tenant_id: str,
|
||||
account_id: str | None,
|
||||
app_id: str,
|
||||
target_account_id: str,
|
||||
payload: ReplaceUserAccessPolicies,
|
||||
target_account_id: str | None = None,
|
||||
) -> ReplaceUserAccessPoliciesResponse:
|
||||
request_data = payload.model_dump(mode="json")
|
||||
data = _inner_call(
|
||||
"PUT",
|
||||
f"{_INNER_PREFIX}/apps/user-access-policies",
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
params={"app_id": app_id, "account_id": target_account_id},
|
||||
json=request_data,
|
||||
json=payload.model_dump(mode="json"),
|
||||
)
|
||||
return ReplaceUserAccessPoliciesResponse.model_validate(data or {})
|
||||
|
||||
|
||||
@ -571,71 +571,11 @@ def test_app_create_api_attaches_permission_keys(app, app_module):
|
||||
"batch_get",
|
||||
lambda tenant_id, account_id, app_ids, session: {"app-new": ["app.acl.view_layout", "app.acl.edit"]},
|
||||
)
|
||||
initialize_rbac = MagicMock()
|
||||
monkeypatch.setattr(app_module, "_initialize_created_app_rbac_access", initialize_rbac)
|
||||
|
||||
resp, status = method(app_module.AppListApi(), "tenant-1", SimpleNamespace(id="acct-1"))
|
||||
|
||||
assert status == 201
|
||||
assert resp["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
|
||||
initialize_rbac.assert_called_once_with("tenant-1", "acct-1", "app-new")
|
||||
|
||||
|
||||
def test_initialize_created_app_rbac_access_batches_workspace_members(app_module, monkeypatch):
|
||||
monkeypatch.setattr(app_module.dify_config, "RBAC_ENABLED", True)
|
||||
monkeypatch.setattr(
|
||||
app_module,
|
||||
"_iter_tenant_member_account_id_batches",
|
||||
lambda tenant_id, batch_size: iter([["acct-1", "acct-2"], ["acct-3"]]),
|
||||
)
|
||||
replace_whitelist = MagicMock()
|
||||
replace_user_access_policies = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
app_module.enterprise_rbac_service.RBACService.AppAccess,
|
||||
"replace_whitelist",
|
||||
replace_whitelist,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_module.enterprise_rbac_service.RBACService.AppAccess,
|
||||
"replace_user_access_policies",
|
||||
replace_user_access_policies,
|
||||
)
|
||||
|
||||
app_module._initialize_created_app_rbac_access("tenant-1", "actor-1", "app-1")
|
||||
|
||||
replace_whitelist.assert_called_once()
|
||||
assert replace_whitelist.call_args.kwargs["payload"].scope is app_module.RBACResourceWhitelistScope.ALL
|
||||
assert replace_user_access_policies.call_count == 2
|
||||
assert replace_user_access_policies.call_args_list[0].kwargs["payload"].account_ids == ["acct-1", "acct-2"]
|
||||
assert replace_user_access_policies.call_args_list[1].kwargs["payload"].account_ids == ["acct-3"]
|
||||
for call in replace_user_access_policies.call_args_list:
|
||||
assert call.kwargs["payload"].access_policy_ids == [app_module.APP_RBAC_DEFAULT_ACCESS_POLICY_ID]
|
||||
|
||||
|
||||
def test_iter_tenant_member_account_id_batches_uses_offset_limit(app_module, monkeypatch):
|
||||
class _FakeScalarResult:
|
||||
def __init__(self, items):
|
||||
self.items = items
|
||||
|
||||
def all(self):
|
||||
return self.items
|
||||
|
||||
offsets = []
|
||||
|
||||
def scalars(stmt):
|
||||
offsets.append(stmt._offset_clause.value)
|
||||
if len(offsets) == 1:
|
||||
return _FakeScalarResult(["acct-1", "acct-2"])
|
||||
if len(offsets) == 2:
|
||||
return _FakeScalarResult(["acct-3"])
|
||||
return _FakeScalarResult([])
|
||||
|
||||
monkeypatch.setattr(app_module.db, "session", SimpleNamespace(scalars=scalars))
|
||||
|
||||
batches = list(app_module._iter_tenant_member_account_id_batches("tenant-1", 2))
|
||||
|
||||
assert batches == [["acct-1", "acct-2"], ["acct-3"]]
|
||||
assert offsets == [0, 2, 4]
|
||||
|
||||
|
||||
def test_app_list_api_attaches_permission_keys(app, app_module):
|
||||
|
||||
@ -58,23 +58,6 @@ describe('ExternalAttributionRecorder', () => {
|
||||
expect(firstArg?.searchParams?.get('slug')).toBe('get-started-with-dify')
|
||||
})
|
||||
|
||||
it('seeds attribution from the redirect_url when auth redirects away from the landing url', async () => {
|
||||
setSearchParams(`redirect_url=${encodeURIComponent('/apps?utm_source=dify_blog&slug=buildaisupportassistantwithdify')}`)
|
||||
|
||||
render(<ExternalAttributionRecorder />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getUtmInfoCookie()).toEqual({
|
||||
utm_source: 'dify_blog',
|
||||
slug: 'buildaisupportassistantwithdify',
|
||||
})
|
||||
})
|
||||
expect(mockRememberCreateAppExternalAttribution).toHaveBeenCalledTimes(1)
|
||||
const firstArg = mockRememberCreateAppExternalAttribution.mock.calls[0]?.[0]
|
||||
expect(firstArg?.searchParams?.get('utm_source')).toBe('dify_blog')
|
||||
expect(firstArg?.searchParams?.get('slug')).toBe('buildaisupportassistantwithdify')
|
||||
})
|
||||
|
||||
it('does nothing without a utm_source', () => {
|
||||
setSearchParams('slug=get-started-with-dify')
|
||||
|
||||
@ -84,15 +67,6 @@ describe('ExternalAttributionRecorder', () => {
|
||||
expect(mockRememberCreateAppExternalAttribution).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing for cross-origin redirect_url attribution params', () => {
|
||||
setSearchParams(`redirect_url=${encodeURIComponent('https://example.com/apps?utm_source=dify_blog&slug=get-started-with-dify')}`)
|
||||
|
||||
render(<ExternalAttributionRecorder />)
|
||||
|
||||
expect(getUtmInfoCookie()).toBeNull()
|
||||
expect(mockRememberCreateAppExternalAttribution).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('overwrites a stale utm_info cookie with the latest campaign params', async () => {
|
||||
Cookies.set('utm_info', JSON.stringify({ utm_source: 'newsletter', slug: 'launch-week' }))
|
||||
setSearchParams('utm_source=dify_blog&slug=get-started-with-dify')
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import * as amplitude from '@amplitude/analytics-browser'
|
||||
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
import { AMPLITUDE_API_KEY, isAmplitudeEnabled } from '@/config'
|
||||
|
||||
export type AmplitudeInitializationOptions = {
|
||||
sessionReplaySampleRate?: number
|
||||
}
|
||||
|
||||
// let isAmplitudeInitialized = false
|
||||
let isAmplitudeInitialized = false
|
||||
|
||||
// Map URL pathname to English page name for consistent Amplitude tracking
|
||||
const getEnglishPageName = (pathname: string): string => {
|
||||
@ -48,13 +49,13 @@ const createPageNameEnrichmentPlugin = (): amplitude.Types.EnrichmentPlugin => {
|
||||
export const ensureAmplitudeInitialized = ({
|
||||
sessionReplaySampleRate = 0.5,
|
||||
}: AmplitudeInitializationOptions = {}) => {
|
||||
// if (!isAmplitudeEnabled || isAmplitudeInitialized)
|
||||
// return
|
||||
if (!isAmplitudeEnabled || isAmplitudeInitialized)
|
||||
return
|
||||
|
||||
// isAmplitudeInitialized = true
|
||||
isAmplitudeInitialized = true
|
||||
|
||||
try {
|
||||
amplitude.init('83d4855862d9264e505e359aac17289e', {
|
||||
amplitude.init(AMPLITUDE_API_KEY, {
|
||||
defaultTracking: {
|
||||
sessions: true,
|
||||
pageViews: true,
|
||||
@ -70,7 +71,7 @@ export const ensureAmplitudeInitialized = ({
|
||||
}))
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
isAmplitudeInitialized = false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@ -490,6 +490,23 @@ describe('chat utils - url params and answer helpers', () => {
|
||||
expect(a1!.children![1]!.children![0]!.siblingIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('buildChatItemTree keeps out-of-order children under their parent when the parent appears later', () => {
|
||||
const list: IChatItem[] = [
|
||||
{ id: 'q2', isAnswer: false, parentMessageId: 'a1' } as IChatItem,
|
||||
{ id: 'a2', isAnswer: true } as IChatItem,
|
||||
{ id: 'q1', isAnswer: false, parentMessageId: null } as IChatItem,
|
||||
{ id: 'a1', isAnswer: true } as IChatItem,
|
||||
]
|
||||
|
||||
const tree = buildChatItemTree(list)
|
||||
|
||||
expect(tree).toHaveLength(1)
|
||||
expect(tree[0]!.id).toBe('q1')
|
||||
expect(tree[0]!.children![0]!.id).toBe('a1')
|
||||
expect(tree[0]!.children![0]!.children![0]!.id).toBe('q2')
|
||||
expect(tree[0]!.children![0]!.children![0]!.children![0]!.id).toBe('a2')
|
||||
})
|
||||
|
||||
it('getThreadMessages node without children', () => {
|
||||
const tree = [{ id: 'q1', isAnswer: false }]
|
||||
const thread = getThreadMessages(tree as unknown as ChatItemInTree[], 'q1')
|
||||
|
||||
@ -114,6 +114,16 @@ function buildChatItemTree(allMessages: IChatItem[]): ChatItemInTree[] {
|
||||
const map: Record<string, ChatItemInTree> = {}
|
||||
const rootNodes: ChatItemInTree[] = []
|
||||
const childrenCount: Record<string, number> = {}
|
||||
const messageIds = new Set(allMessages.map(item => item.id))
|
||||
const pendingChildren: Record<string, ChatItemInTree[]> = {}
|
||||
const appendPendingChildren = (parentId: string) => {
|
||||
const children = pendingChildren[parentId]
|
||||
if (!children)
|
||||
return
|
||||
|
||||
map[parentId]?.children?.push(...children)
|
||||
delete pendingChildren[parentId]
|
||||
}
|
||||
|
||||
let lastAppendedLegacyAnswer: ChatItemInTree | null = null
|
||||
for (let i = 0; i < allMessages.length; i += 2) {
|
||||
@ -144,6 +154,8 @@ function buildChatItemTree(allMessages: IChatItem[]): ChatItemInTree[] {
|
||||
|
||||
// Connect question and answer
|
||||
questionNode.children!.push(answerNode)
|
||||
appendPendingChildren(question.id)
|
||||
appendPendingChildren(answer.id)
|
||||
|
||||
// Append to parent or add to root
|
||||
if (isLegacy) {
|
||||
@ -157,10 +169,14 @@ function buildChatItemTree(allMessages: IChatItem[]): ChatItemInTree[] {
|
||||
else {
|
||||
if (
|
||||
!parentMessageId
|
||||
|| !allMessages.some(item => item.id === parentMessageId) // parent message might not be fetched yet, in this case we will append the question to the root nodes
|
||||
|| !messageIds.has(parentMessageId) // parent message might not be fetched yet, in this case we will append the question to the root nodes
|
||||
) {
|
||||
rootNodes.push(questionNode)
|
||||
}
|
||||
else if (!map[parentMessageId]) {
|
||||
pendingChildren[parentMessageId] = pendingChildren[parentMessageId] || []
|
||||
pendingChildren[parentMessageId]!.push(questionNode)
|
||||
}
|
||||
else {
|
||||
map[parentMessageId]!.children!.push(questionNode)
|
||||
}
|
||||
|
||||
@ -10,44 +10,6 @@ const UTM_INFO_COOKIE = 'utm_info'
|
||||
const UTM_INFO_COOKIE_EXPIRES_DAYS = 1
|
||||
const UTM_INFO_QUERY_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'slug'] as const
|
||||
|
||||
type SearchParamReader = {
|
||||
get: (name: string) => string | null
|
||||
}
|
||||
|
||||
const normalizeString = (value?: string | null) => {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
const getSearchParamValue = (searchParams: SearchParamReader, key: string) =>
|
||||
normalizeString(searchParams.get(key))
|
||||
|
||||
const parseRedirectUrlSearchParams = (redirectUrl: string) => {
|
||||
const baseUrl = window.location.origin
|
||||
|
||||
try {
|
||||
const url = new URL(redirectUrl, baseUrl)
|
||||
if (url.origin !== baseUrl)
|
||||
return null
|
||||
|
||||
return url.searchParams
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const resolveAttributionSearchParams = (searchParams: SearchParamReader): SearchParamReader | null => {
|
||||
if (getSearchParamValue(searchParams, 'utm_source'))
|
||||
return searchParams
|
||||
|
||||
const redirectUrl = getSearchParamValue(searchParams, 'redirect_url')
|
||||
if (!redirectUrl)
|
||||
return null
|
||||
|
||||
return parseRedirectUrlSearchParams(redirectUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures external-campaign params (utm_* + blog `slug`) from the landing URL.
|
||||
*
|
||||
@ -70,16 +32,12 @@ const ExternalAttributionRecorder = () => {
|
||||
if (!IS_CLOUD_EDITION)
|
||||
return
|
||||
|
||||
const attributionSearchParams = resolveAttributionSearchParams(searchParams)
|
||||
if (!attributionSearchParams)
|
||||
return
|
||||
|
||||
const utmSource = getSearchParamValue(attributionSearchParams, 'utm_source')
|
||||
const utmSource = searchParams.get('utm_source')?.trim()
|
||||
if (!utmSource)
|
||||
return
|
||||
|
||||
// create_app conversion attribution (utm_source + slug).
|
||||
rememberCreateAppExternalAttribution({ searchParams: attributionSearchParams })
|
||||
rememberCreateAppExternalAttribution({ searchParams })
|
||||
|
||||
// Seed the utm_info cookie the registration trackers read. A campaign click always
|
||||
// overwrites any previous value, so the most recent blog link wins (last-touch) and
|
||||
@ -87,7 +45,7 @@ const ExternalAttributionRecorder = () => {
|
||||
// mirrors the create_app attribution refreshed just above.
|
||||
const utmInfo: Record<string, string> = {}
|
||||
UTM_INFO_QUERY_KEYS.forEach((key) => {
|
||||
const value = getSearchParamValue(attributionSearchParams, key)
|
||||
const value = searchParams.get(key)?.trim()
|
||||
if (value)
|
||||
utmInfo[key] = value
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user