From dfc123819e5841313928986a4b1a236b1f41523b Mon Sep 17 00:00:00 2001 From: kenwoodjw Date: Tue, 15 Apr 2025 11:34:50 +0800 Subject: [PATCH 01/29] fix basic auth encoding (#18047) Signed-off-by: kenwoodjw --- api/core/workflow/nodes/http_request/executor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/core/workflow/nodes/http_request/executor.py b/api/core/workflow/nodes/http_request/executor.py index bf28222de0..f7fa8d670c 100644 --- a/api/core/workflow/nodes/http_request/executor.py +++ b/api/core/workflow/nodes/http_request/executor.py @@ -1,3 +1,4 @@ +import base64 import json from collections.abc import Mapping from copy import deepcopy @@ -259,7 +260,9 @@ class Executor: if self.auth.config.type == "bearer": headers[authorization.config.header] = f"Bearer {authorization.config.api_key}" elif self.auth.config.type == "basic": - headers[authorization.config.header] = f"Basic {authorization.config.api_key}" + credentials = authorization.config.api_key + encoded_credentials = base64.b64encode(credentials.encode("utf-8")).decode("utf-8") + headers[authorization.config.header] = f"Basic {encoded_credentials}" elif self.auth.config.type == "custom": headers[authorization.config.header] = authorization.config.api_key or "" From 6c167038af2b0c20bc85c9fd54bfdb31480bc267 Mon Sep 17 00:00:00 2001 From: AichiB7A Date: Tue, 15 Apr 2025 11:35:34 +0800 Subject: [PATCH 02/29] [Observability] Instrument with celery (#18029) --- api/extensions/ext_otel.py | 58 ++++++++++++++++++++++++++++---------- api/poetry.lock | 22 ++++++++++++++- api/pyproject.toml | 1 + 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/api/extensions/ext_otel.py b/api/extensions/ext_otel.py index f2da3e0275..59ec0d0686 100644 --- a/api/extensions/ext_otel.py +++ b/api/extensions/ext_otel.py @@ -1,16 +1,20 @@ import atexit +import logging import os import platform import socket +import sys from typing import Union +from celery.signals import worker_init # type: ignore from flask_login import user_loaded_from_request, user_logged_in # type: ignore from opentelemetry import trace from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.celery import CeleryInstrumentor from opentelemetry.instrumentation.flask import FlaskInstrumentor from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor -from opentelemetry.metrics import set_meter_provider +from opentelemetry.metrics import get_meter_provider, set_meter_provider from opentelemetry.propagate import set_global_textmap from opentelemetry.propagators.b3 import B3Format from opentelemetry.propagators.composite import CompositePropagator @@ -24,7 +28,7 @@ from opentelemetry.sdk.trace.export import ( ) from opentelemetry.sdk.trace.sampling import ParentBasedTraceIdRatio from opentelemetry.semconv.resource import ResourceAttributes -from opentelemetry.trace import Span, get_current_span, set_tracer_provider +from opentelemetry.trace import Span, get_current_span, get_tracer_provider, set_tracer_provider from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator from opentelemetry.trace.status import StatusCode @@ -96,22 +100,37 @@ def init_app(app: DifyApp): export_timeout_millis=dify_config.OTEL_METRIC_EXPORT_TIMEOUT, ) set_meter_provider(MeterProvider(resource=resource, metric_readers=[reader])) - - def response_hook(span: Span, status: str, response_headers: list): - if span and span.is_recording(): - if status.startswith("2"): - span.set_status(StatusCode.OK) - else: - span.set_status(StatusCode.ERROR, status) - - instrumentor = FlaskInstrumentor() - instrumentor.instrument_app(app, response_hook=response_hook) - with app.app_context(): - engines = list(app.extensions["sqlalchemy"].engines.values()) - SQLAlchemyInstrumentor().instrument(enable_commenter=True, engines=engines) + if not is_celery_worker(): + init_flask_instrumentor(app) + CeleryInstrumentor(tracer_provider=get_tracer_provider(), meter_provider=get_meter_provider()).instrument() + init_sqlalchemy_instrumentor(app) atexit.register(shutdown_tracer) +def is_celery_worker(): + return "celery" in sys.argv[0].lower() + + +def init_flask_instrumentor(app: DifyApp): + def response_hook(span: Span, status: str, response_headers: list): + if span and span.is_recording(): + if status.startswith("2"): + span.set_status(StatusCode.OK) + else: + span.set_status(StatusCode.ERROR, status) + + instrumentor = FlaskInstrumentor() + if dify_config.DEBUG: + logging.info("Initializing Flask instrumentor") + instrumentor.instrument_app(app, response_hook=response_hook) + + +def init_sqlalchemy_instrumentor(app: DifyApp): + with app.app_context(): + engines = list(app.extensions["sqlalchemy"].engines.values()) + SQLAlchemyInstrumentor().instrument(enable_commenter=True, engines=engines) + + def setup_context_propagation(): # Configure propagators set_global_textmap( @@ -124,6 +143,15 @@ def setup_context_propagation(): ) +@worker_init.connect(weak=False) +def init_celery_worker(*args, **kwargs): + tracer_provider = get_tracer_provider() + metric_provider = get_meter_provider() + if dify_config.DEBUG: + logging.info("Initializing OpenTelemetry for Celery worker") + CeleryInstrumentor(tracer_provider=tracer_provider, meter_provider=metric_provider).instrument() + + def shutdown_tracer(): provider = trace.get_tracer_provider() if hasattr(provider, "force_flush"): diff --git a/api/poetry.lock b/api/poetry.lock index 3ee71c5c58..2901553682 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -5537,6 +5537,26 @@ opentelemetry-util-http = "0.48b0" [package.extras] instruments = ["asgiref (>=3.0,<4.0)"] +[[package]] +name = "opentelemetry-instrumentation-celery" +version = "0.48b0" +description = "OpenTelemetry Celery Instrumentation" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "opentelemetry_instrumentation_celery-0.48b0-py3-none-any.whl", hash = "sha256:c1904e38cc58fb2a33cd657d6e296285c5ffb0dca3f164762f94b905e5abc88e"}, + {file = "opentelemetry_instrumentation_celery-0.48b0.tar.gz", hash = "sha256:1d33aa6c4a1e6c5d17a64215245208a96e56c9d07611685dbae09a557704af26"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.12,<2.0" +opentelemetry-instrumentation = "0.48b0" +opentelemetry-semantic-conventions = "0.48b0" + +[package.extras] +instruments = ["celery (>=4.0,<6.0)"] + [[package]] name = "opentelemetry-instrumentation-fastapi" version = "0.48b0" @@ -10560,4 +10580,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.13" -content-hash = "f433068e3819e71110da806dc5f0e80db64d439499c56126ac67d31c1ac30391" +content-hash = "23f5322fb6a6397f1cabb206d6806284f95a277ae1f1269df727f58a49ce4384" diff --git a/api/pyproject.toml b/api/pyproject.toml index 2bacf3b1dc..a06f1747d4 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -56,6 +56,7 @@ opentelemetry-exporter-otlp-proto-common = "1.27.0" opentelemetry-exporter-otlp-proto-grpc = "1.27.0" opentelemetry-exporter-otlp-proto-http = "1.27.0" opentelemetry-instrumentation = "0.48b0" +opentelemetry-instrumentation-celery = "0.48b0" opentelemetry-instrumentation-flask = "0.48b0" opentelemetry-instrumentation-sqlalchemy = "0.48b0" opentelemetry-propagator-b3 = "1.27.0" From 05b8b2a30cba254a207ea301542c053ea05fb01d Mon Sep 17 00:00:00 2001 From: "Junjie.M" <118170653@qq.com> Date: Tue, 15 Apr 2025 13:51:40 +0800 Subject: [PATCH 03/29] fix: plugin parameter type TOOLS_SELECTOR parameter not validation required (#18060) --- api/core/plugin/entities/parameters.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/core/plugin/entities/parameters.py b/api/core/plugin/entities/parameters.py index 7d858bd7d5..895dd0d0fc 100644 --- a/api/core/plugin/entities/parameters.py +++ b/api/core/plugin/entities/parameters.py @@ -131,7 +131,7 @@ def cast_parameter_value(typ: enum.StrEnum, value: Any, /): raise ValueError("The selector must be a dictionary.") return value case PluginParameterType.TOOLS_SELECTOR: - if not isinstance(value, list): + if value and not isinstance(value, list): raise ValueError("The tools selector must be a list.") return value case _: @@ -147,7 +147,7 @@ def init_frontend_parameter(rule: PluginParameter, type: enum.StrEnum, value: An init frontend parameter by rule """ parameter_value = value - if not parameter_value and parameter_value != 0 and type != PluginParameterType.TOOLS_SELECTOR: + if not parameter_value and parameter_value != 0: # get default value parameter_value = rule.default if not parameter_value and rule.required: From 5dd9acbe44e65a8149acc510a7262497eb7733b4 Mon Sep 17 00:00:00 2001 From: huangzhuo1949 <167434202+huangzhuo1949@users.noreply.github.com> Date: Tue, 15 Apr 2025 15:36:44 +0800 Subject: [PATCH 04/29] fix: cot agent chinese json bug (#18073) Co-authored-by: huangzhuo --- api/core/agent/cot_agent_runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/core/agent/cot_agent_runner.py b/api/core/agent/cot_agent_runner.py index ae70ff2bf1..feb8abf6ef 100644 --- a/api/core/agent/cot_agent_runner.py +++ b/api/core/agent/cot_agent_runner.py @@ -191,7 +191,7 @@ class CotAgentRunner(BaseAgentRunner, ABC): # action is final answer, return final answer directly try: if isinstance(scratchpad.action.action_input, dict): - final_answer = json.dumps(scratchpad.action.action_input) + final_answer = json.dumps(scratchpad.action.action_input, ensure_ascii=False) elif isinstance(scratchpad.action.action_input, str): final_answer = scratchpad.action.action_input else: From 438463b1c47649a668afb74b73b3b2a1dfe26d1e Mon Sep 17 00:00:00 2001 From: Hash Brown Date: Tue, 15 Apr 2025 15:37:08 +0800 Subject: [PATCH 05/29] feat: edit question in Chat (#17961) --- .../debug/debug-with-single-model/index.tsx | 11 ++- .../chat/chat-with-history/chat-wrapper.tsx | 23 ++--- .../base/chat/chat/answer/index.tsx | 41 ++++---- .../base/chat/chat/content-switch.tsx | 39 ++++++++ web/app/components/base/chat/chat/index.tsx | 3 +- .../components/base/chat/chat/question.tsx | 97 ++++++++++++++++++- .../chat/embedded-chatbot/chat-wrapper.tsx | 11 ++- .../panel/debug-and-preview/chat-wrapper.tsx | 11 ++- web/i18n/en-US/common.ts | 1 + web/i18n/zh-Hans/common.ts | 1 + 10 files changed, 191 insertions(+), 47 deletions(-) create mode 100644 web/app/components/base/chat/chat/content-switch.tsx diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx index bb60648adb..d439b00939 100644 --- a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx +++ b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx @@ -21,6 +21,7 @@ import { useFeatures } from '@/app/components/base/features/hooks' import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils' import type { InputForm } from '@/app/components/base/chat/chat/type' import { canFindTool } from '@/utils' +import type { FileEntity } from '@/app/components/base/file-uploader/types' type DebugWithSingleModelProps = { checkCanSend?: () => boolean @@ -125,10 +126,14 @@ const DebugWithSingleModel = ( ) }, [appId, chatList, checkCanSend, completionParams, config, handleSend, inputs, modelConfig.mode, modelConfig.model_id, modelConfig.provider, textGenerationModelList]) - const doRegenerate = useCallback((chatItem: ChatItemInTree) => { - const question = chatList.find(item => item.id === chatItem.parentMessageId)! + const doRegenerate = useCallback((chatItem: ChatItemInTree, editedQuestion?: { message: string, files?: FileEntity[] }) => { + const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! const parentAnswer = chatList.find(item => item.id === question.parentMessageId) - doSend(question.content, question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null) + doSend(editedQuestion ? editedQuestion.message : question.content, + editedQuestion ? editedQuestion.files : question.message_files, + true, + isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null, + ) }, [chatList, doSend]) const allToolIcons = useMemo(() => { diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx index a23be569cc..63de13596f 100644 --- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx +++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -22,6 +22,7 @@ import AnswerIcon from '@/app/components/base/answer-icon' import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions' import { Markdown } from '@/app/components/base/markdown' import cn from '@/utils/classnames' +import type { FileEntity } from '../../file-uploader/types' const ChatWrapper = () => { const { @@ -139,22 +140,16 @@ const ChatWrapper = () => { isPublicAPI: !isInstalledApp, }, ) - }, [ - chatList, - handleNewConversationCompleted, - handleSend, - currentConversationId, - currentConversationItem, - currentConversationInputs, - newConversationInputs, - isInstalledApp, - appId, - ]) + }, [chatList, handleNewConversationCompleted, handleSend, currentConversationId, currentConversationInputs, newConversationInputs, isInstalledApp, appId]) - const doRegenerate = useCallback((chatItem: ChatItemInTree) => { - const question = chatList.find(item => item.id === chatItem.parentMessageId)! + const doRegenerate = useCallback((chatItem: ChatItemInTree, editedQuestion?: { message: string, files?: FileEntity[] }) => { + const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! const parentAnswer = chatList.find(item => item.id === question.parentMessageId) - doSend(question.content, question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null) + doSend(editedQuestion ? editedQuestion.message : question.content, + editedQuestion ? editedQuestion.files : question.message_files, + true, + isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null, + ) }, [chatList, doSend]) const messageList = useMemo(() => { diff --git a/web/app/components/base/chat/chat/answer/index.tsx b/web/app/components/base/chat/chat/answer/index.tsx index 349bc7477e..3722556931 100644 --- a/web/app/components/base/chat/chat/answer/index.tsx +++ b/web/app/components/base/chat/chat/answer/index.tsx @@ -2,7 +2,7 @@ import type { FC, ReactNode, } from 'react' -import { memo, useEffect, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import type { ChatConfig, @@ -19,9 +19,9 @@ import Citation from '@/app/components/base/chat/chat/citation' import { EditTitle } from '@/app/components/app/annotation/edit-annotation-modal/edit-item' import type { AppData } from '@/models/share' import AnswerIcon from '@/app/components/base/answer-icon' -import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' import cn from '@/utils/classnames' import { FileList } from '@/app/components/base/file-uploader' +import ContentSwitch from '../content-switch' type AnswerProps = { item: ChatItem @@ -100,12 +100,19 @@ const Answer: FC = ({ } }, []) + const handleSwitchSibling = useCallback((direction: 'prev' | 'next') => { + if (direction === 'prev') + item.prevSibling && switchSibling?.(item.prevSibling) + else + item.nextSibling && switchSibling?.(item.nextSibling) + }, [switchSibling, item.prevSibling, item.nextSibling]) + return (
{answerIcon || } {responding && ( -
+
)} @@ -208,23 +215,17 @@ const Answer: FC = ({ ) } - {item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined &&
- - {item.siblingIndex + 1} / {item.siblingCount} - -
} + { + item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined && ( + + ) + }
diff --git a/web/app/components/base/chat/chat/content-switch.tsx b/web/app/components/base/chat/chat/content-switch.tsx new file mode 100644 index 0000000000..cf428f4cb4 --- /dev/null +++ b/web/app/components/base/chat/chat/content-switch.tsx @@ -0,0 +1,39 @@ +import { ChevronRight } from '../../icons/src/vender/line/arrows' + +export default function ContentSwitch({ + count, + currentIndex, + prevDisabled, + nextDisabled, + switchSibling, +}: { + count?: number + currentIndex?: number + prevDisabled: boolean + nextDisabled: boolean + switchSibling: (direction: 'prev' | 'next') => void +}) { + return ( + count && count > 1 && currentIndex !== undefined && ( +
+ + + {currentIndex + 1} / {count} + + +
+ ) + ) +} diff --git a/web/app/components/base/chat/chat/index.tsx b/web/app/components/base/chat/chat/index.tsx index fe38e405a1..27952fe468 100644 --- a/web/app/components/base/chat/chat/index.tsx +++ b/web/app/components/base/chat/chat/index.tsx @@ -208,7 +208,7 @@ const Chat: FC = ({ useEffect(() => { if (!sidebarCollapseState) setTimeout(() => handleWindowResize(), 200) - }, [sidebarCollapseState]) + }, [handleWindowResize, sidebarCollapseState]) const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend @@ -265,6 +265,7 @@ const Chat: FC = ({ item={item} questionIcon={questionIcon} theme={themeBuilder?.theme} + switchSibling={switchSibling} /> ) }) diff --git a/web/app/components/base/chat/chat/question.tsx b/web/app/components/base/chat/chat/question.tsx index 4a2518061a..af4d64964c 100644 --- a/web/app/components/base/chat/chat/question.tsx +++ b/web/app/components/base/chat/chat/question.tsx @@ -4,46 +4,137 @@ import type { } from 'react' import { memo, + useCallback, + useState, } from 'react' import type { ChatItem } from '../types' import type { Theme } from '../embedded-chatbot/theme/theme-context' import { CssTransform } from '../embedded-chatbot/theme/utils' +import ContentSwitch from './content-switch' import { User } from '@/app/components/base/icons/src/public/avatar' import { Markdown } from '@/app/components/base/markdown' import { FileList } from '@/app/components/base/file-uploader' +import ActionButton from '../../action-button' +import { RiClipboardLine, RiEditLine } from '@remixicon/react' +import Toast from '../../toast' +import copy from 'copy-to-clipboard' +import { useTranslation } from 'react-i18next' +import cn from '@/utils/classnames' +import Textarea from 'react-textarea-autosize' +import Button from '../../button' +import { useChatContext } from './context' type QuestionProps = { item: ChatItem questionIcon?: ReactNode theme: Theme | null | undefined + switchSibling?: (siblingMessageId: string) => void } + const Question: FC = ({ item, questionIcon, theme, + switchSibling, }) => { + const { t } = useTranslation() + const { content, message_files, } = item + const { + onRegenerate, + } = useChatContext() + + const [isEditing, setIsEditing] = useState(false) + const [editedContent, setEditedContent] = useState(content) + + const handleEdit = useCallback(() => { + setIsEditing(true) + setEditedContent(content) + }, [content]) + + const handleResend = useCallback(() => { + setIsEditing(false) + onRegenerate?.(item, { message: editedContent, files: message_files }) + }, [editedContent, message_files, item, onRegenerate]) + + const handleCancelEditing = useCallback(() => { + setIsEditing(false) + setEditedContent(content) + }, [content]) + + const handleSwitchSibling = useCallback((direction: 'prev' | 'next') => { + if (direction === 'prev') + item.prevSibling && switchSibling?.(item.prevSibling) + else + item.nextSibling && switchSibling?.(item.nextSibling) + }, [switchSibling, item.prevSibling, item.nextSibling]) + return (
-
+
+
+
+ { + copy(content) + Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') }) + }}> + + + + + +
+
{ !!message_files?.length && ( ) } - + { !isEditing + ? + :
+
+