diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py
index 037f13d297..0724a6355d 100644
--- a/api/controllers/console/app/app.py
+++ b/api/controllers/console/app/app.py
@@ -15,11 +15,12 @@ from controllers.console.wraps import (
setup_required,
)
from core.ops.ops_trace_manager import OpsTraceManager
+from core.workflow.enums import NodeType
from extensions.ext_database import db
from fields.app_fields import app_detail_fields, app_detail_fields_with_site, app_pagination_fields
from libs.login import current_account_with_tenant, login_required
from libs.validators import validate_description_length
-from models import App
+from models import App, Workflow
from services.app_dsl_service import AppDslService, ImportMode
from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService
@@ -106,6 +107,35 @@ class AppListApi(Resource):
if str(app.id) in res:
app.access_mode = res[str(app.id)].access_mode
+ workflow_capable_app_ids = [
+ str(app.id) for app in app_pagination.items if app.mode in {"workflow", "advanced-chat"}
+ ]
+ draft_trigger_app_ids: set[str] = set()
+ if workflow_capable_app_ids:
+ draft_workflows = (
+ db.session.execute(
+ select(Workflow).where(
+ Workflow.version == Workflow.VERSION_DRAFT,
+ Workflow.app_id.in_(workflow_capable_app_ids),
+ )
+ )
+ .scalars()
+ .all()
+ )
+ trigger_node_types = {
+ NodeType.TRIGGER_WEBHOOK,
+ NodeType.TRIGGER_SCHEDULE,
+ NodeType.TRIGGER_PLUGIN,
+ }
+ for workflow in draft_workflows:
+ for _, node_data in workflow.walk_nodes():
+ if node_data.get("type") in trigger_node_types:
+ draft_trigger_app_ids.add(str(workflow.app_id))
+ break
+
+ for app in app_pagination.items:
+ app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
+
return marshal(app_pagination, app_pagination_fields), 200
@api.doc("create_app")
diff --git a/api/core/rag/extractor/word_extractor.py b/api/core/rag/extractor/word_extractor.py
index 1a9704688a..c7a5568866 100644
--- a/api/core/rag/extractor/word_extractor.py
+++ b/api/core/rag/extractor/word_extractor.py
@@ -152,13 +152,15 @@ class WordExtractor(BaseExtractor):
# Initialize a row, all of which are empty by default
row_cells = [""] * total_cols
col_index = 0
- for cell in row.cells:
+ while col_index < len(row.cells):
# make sure the col_index is not out of range
- while col_index < total_cols and row_cells[col_index] != "":
+ while col_index < len(row.cells) and row_cells[col_index] != "":
col_index += 1
# if col_index is out of range the loop is jumped
- if col_index >= total_cols:
+ if col_index >= len(row.cells):
break
+ # get the correct cell
+ cell = row.cells[col_index]
cell_content = self._parse_cell(cell, image_map).strip()
cell_colspan = cell.grid_span or 1
for i in range(cell_colspan):
diff --git a/api/fields/app_fields.py b/api/fields/app_fields.py
index 1f14d663b8..7191933eed 100644
--- a/api/fields/app_fields.py
+++ b/api/fields/app_fields.py
@@ -116,6 +116,7 @@ app_partial_fields = {
"access_mode": fields.String,
"create_user_name": fields.String,
"author_name": fields.String,
+ "has_draft_trigger": fields.Boolean,
}
diff --git a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py
new file mode 100644
index 0000000000..3635e4dbf9
--- /dev/null
+++ b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py
@@ -0,0 +1,49 @@
+"""Primarily used for testing merged cell scenarios"""
+
+from docx import Document
+
+from core.rag.extractor.word_extractor import WordExtractor
+
+
+def _generate_table_with_merged_cells():
+ doc = Document()
+
+ """
+ The table looks like this:
+ +-----+-----+-----+
+ | 1-1 & 1-2 | 1-3 |
+ +-----+-----+-----+
+ | 2-1 | 2-2 | 2-3 |
+ | & |-----+-----+
+ | 3-1 | 3-2 | 3-3 |
+ +-----+-----+-----+
+ """
+ table = doc.add_table(rows=3, cols=3)
+ table.style = "Table Grid"
+
+ for i in range(3):
+ for j in range(3):
+ cell = table.cell(i, j)
+ cell.text = f"{i + 1}-{j + 1}"
+
+ # Merge cells
+ cell_0_0 = table.cell(0, 0)
+ cell_0_1 = table.cell(0, 1)
+ merged_cell_1 = cell_0_0.merge(cell_0_1)
+ merged_cell_1.text = "1-1 & 1-2"
+
+ cell_1_0 = table.cell(1, 0)
+ cell_2_0 = table.cell(2, 0)
+ merged_cell_2 = cell_1_0.merge(cell_2_0)
+ merged_cell_2.text = "2-1 & 3-1"
+
+ ground_truth = [["1-1 & 1-2", "", "1-3"], ["2-1 & 3-1", "2-2", "2-3"], ["2-1 & 3-1", "3-2", "3-3"]]
+
+ return doc.tables[0], ground_truth
+
+
+def test_parse_row():
+ table, gt = _generate_table_with_merged_cells()
+ extractor = object.__new__(WordExtractor)
+ for idx, row in enumerate(table.rows):
+ assert extractor._parse_row(row, {}, 3) == gt[idx]
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_database_utils.py b/api/tests/unit_tests/core/workflow/graph_engine/test_database_utils.py
new file mode 100644
index 0000000000..ae7dd48bb1
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/graph_engine/test_database_utils.py
@@ -0,0 +1,46 @@
+"""
+Utilities for detecting if database service is available for workflow tests.
+"""
+
+import psycopg2
+import pytest
+
+from configs import dify_config
+
+
+def is_database_available() -> bool:
+ """
+ Check if the database service is available by attempting to connect to it.
+
+ Returns:
+ True if database is available, False otherwise.
+ """
+ try:
+ # Try to establish a database connection using a context manager
+ with psycopg2.connect(
+ host=dify_config.DB_HOST,
+ port=dify_config.DB_PORT,
+ database=dify_config.DB_DATABASE,
+ user=dify_config.DB_USERNAME,
+ password=dify_config.DB_PASSWORD,
+ connect_timeout=2, # 2 second timeout
+ ) as conn:
+ pass # Connection established and will be closed automatically
+ return True
+ except (psycopg2.OperationalError, psycopg2.Error):
+ return False
+
+
+def skip_if_database_unavailable():
+ """
+ Pytest skip decorator that skips tests when database service is unavailable.
+
+ Usage:
+ @skip_if_database_unavailable()
+ def test_my_workflow():
+ ...
+ """
+ return pytest.mark.skipif(
+ not is_database_available(),
+ reason="Database service is not available (connection refused or authentication failed)",
+ )
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_iteration_flatten_output.py b/api/tests/unit_tests/core/workflow/graph_engine/test_iteration_flatten_output.py
index f2095a8a70..98f344babf 100644
--- a/api/tests/unit_tests/core/workflow/graph_engine/test_iteration_flatten_output.py
+++ b/api/tests/unit_tests/core/workflow/graph_engine/test_iteration_flatten_output.py
@@ -6,9 +6,11 @@ This module tests the iteration node's ability to:
2. Preserve nested array structure when flatten_output=False
"""
+from .test_database_utils import skip_if_database_unavailable
from .test_table_runner import TableTestRunner, WorkflowTestCase
+@skip_if_database_unavailable()
def test_iteration_with_flatten_output_enabled():
"""
Test iteration node with flatten_output=True (default behavior).
@@ -37,6 +39,7 @@ def test_iteration_with_flatten_output_enabled():
)
+@skip_if_database_unavailable()
def test_iteration_with_flatten_output_disabled():
"""
Test iteration node with flatten_output=False.
@@ -65,6 +68,7 @@ def test_iteration_with_flatten_output_disabled():
)
+@skip_if_database_unavailable()
def test_iteration_flatten_output_comparison():
"""
Run both flatten_output configurations in parallel to verify the difference.
diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx
index 564eb493e5..8356cfd31c 100644
--- a/web/app/components/apps/app-card.tsx
+++ b/web/app/components/apps/app-card.tsx
@@ -282,21 +282,23 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
>
)}
{
- (!systemFeatures.webapp_auth.enabled)
- ? <>
-
-
- >
- : !(isGettingUserCanAccessApp || !userCanAccessApp?.result) && (
- <>
+ !app.has_draft_trigger && (
+ (!systemFeatures.webapp_auth.enabled)
+ ? <>
>
- )
+ : !(isGettingUserCanAccessApp || !userCanAccessApp?.result) && (
+ <>
+
+
+ >
+ )
+ )
}
{
diff --git a/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx b/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx
index 95bb339db9..f90fd7ac60 100644
--- a/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx
+++ b/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx
@@ -121,7 +121,7 @@ const RegenerationModal: FC = ({
})
return (
-
+
{!loading && !updateSucceeded && }
{loading && !updateSucceeded && }
{!loading && updateSucceeded && }
diff --git a/web/app/components/datasets/documents/detail/completed/index.tsx b/web/app/components/datasets/documents/detail/completed/index.tsx
index 8fa167f976..09c63d54a1 100644
--- a/web/app/components/datasets/documents/detail/completed/index.tsx
+++ b/web/app/components/datasets/documents/detail/completed/index.tsx
@@ -124,6 +124,7 @@ const Completed: FC = ({
const [limit, setLimit] = useState(DEFAULT_LIMIT)
const [fullScreen, setFullScreen] = useState(false)
const [showNewChildSegmentModal, setShowNewChildSegmentModal] = useState(false)
+ const [isRegenerationModalOpen, setIsRegenerationModalOpen] = useState(false)
const segmentListRef = useRef(null)
const childSegmentListRef = useRef(null)
@@ -669,6 +670,7 @@ const Completed: FC = ({
onClose={onCloseSegmentDetail}
showOverlay={false}
needCheckChunks
+ modal={isRegenerationModalOpen}
>
= ({
isEditMode={currSegment.isEditMode}
onUpdate={handleUpdateSegment}
onCancel={onCloseSegmentDetail}
+ onModalStateChange={setIsRegenerationModalOpen}
/>
{/* Create New Segment */}
diff --git a/web/app/components/datasets/documents/detail/completed/segment-detail.tsx b/web/app/components/datasets/documents/detail/completed/segment-detail.tsx
index bbd9df1adc..5e5ae6b485 100644
--- a/web/app/components/datasets/documents/detail/completed/segment-detail.tsx
+++ b/web/app/components/datasets/documents/detail/completed/segment-detail.tsx
@@ -27,6 +27,7 @@ type ISegmentDetailProps = {
onCancel: () => void
isEditMode?: boolean
docForm: ChunkingMode
+ onModalStateChange?: (isOpen: boolean) => void
}
/**
@@ -38,6 +39,7 @@ const SegmentDetail: FC = ({
onCancel,
isEditMode,
docForm,
+ onModalStateChange,
}) => {
const { t } = useTranslation()
const [question, setQuestion] = useState(isEditMode ? segInfo?.content || '' : segInfo?.sign_content || '')
@@ -68,11 +70,19 @@ const SegmentDetail: FC = ({
const handleRegeneration = useCallback(() => {
setShowRegenerationModal(true)
- }, [])
+ onModalStateChange?.(true)
+ }, [onModalStateChange])
const onCancelRegeneration = useCallback(() => {
setShowRegenerationModal(false)
- }, [])
+ onModalStateChange?.(false)
+ }, [onModalStateChange])
+
+ const onCloseAfterRegeneration = useCallback(() => {
+ setShowRegenerationModal(false)
+ onModalStateChange?.(false)
+ onCancel() // Close the edit drawer
+ }, [onCancel, onModalStateChange])
const onConfirmRegeneration = useCallback(() => {
onUpdate(segInfo?.id || '', question, answer, keywords, true)
@@ -161,7 +171,7 @@ const SegmentDetail: FC = ({
isShow={showRegenerationModal}
onConfirm={onConfirmRegeneration}
onCancel={onCancelRegeneration}
- onClose={onCancelRegeneration}
+ onClose={onCloseAfterRegeneration}
/>
)
}
diff --git a/web/types/app.ts b/web/types/app.ts
index b7a7f6a48d..73e11d396a 100644
--- a/web/types/app.ts
+++ b/web/types/app.ts
@@ -379,6 +379,8 @@ export type App = {
/** access control */
access_mode: AccessMode
max_active_requests?: number | null
+ /** whether workflow trigger has un-published draft */
+ has_draft_trigger?: boolean
}
export type AppSSO = {