Compare commits

...

20 Commits

Author SHA1 Message Date
hjlarry
232b8eb248 fix CI 2026-03-09 15:41:54 +08:00
hjlarry
4c0d81029f fix CI 2026-03-09 15:29:21 +08:00
autofix-ci[bot]
bacf70c00a [autofix.ci] apply automated fixes 2026-03-09 06:46:39 +00:00
非法操作
942729ba48 Update api/extensions/otel/runtime.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-09 14:44:35 +08:00
非法操作
4f5af0b43c Update api/extensions/otel/runtime.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-09 14:44:27 +08:00
hjlarry
a28cb993b8 Merge remote-tracking branch 'myori/main' into p372 2026-03-09 14:43:03 +08:00
hj24
0aef09d630 feat: support relative mode for message clean command (#32834) 2026-03-09 14:32:35 +08:00
hjlarry
c6dd2ef25a add task label to the cleanup task 2026-03-09 14:30:04 +08:00
wangxiaolei
d2208ad43e fix: fix allow handle value is none (#33031) 2026-03-09 14:20:44 +08:00
非法操作
4a2ba058bb feat: when copy/paste multi nodes not require reconnect them (#32631) 2026-03-09 13:55:12 +08:00
非法操作
654e41d47f fix: workflow_as_tool not work with json input (#32554) 2026-03-09 13:54:54 +08:00
非法操作
ec5409756e feat: keep connections when change node (#31982) 2026-03-09 13:54:10 +08:00
hjlarry
b0e8becd14 add clean message metrics 2026-03-09 13:45:08 +08:00
Olexandr88
8b1ea3a8f5 refactor: deduplicate legacy section mapping in ConfigHelper (#32715) 2026-03-09 13:43:06 +08:00
yyh
f2d3feca66 fix(web): fix tool item text not vertically centered in block selector (#33148) 2026-03-09 13:38:11 +08:00
yyh
0590b09958 feat(web): add context menu primitive and dropdown link item (#33125) 2026-03-09 12:05:38 +08:00
wangxiaolei
66f9fde2fe fix: fix metadata filter condition not extract from {{}} (#33141)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-09 11:51:08 +08:00
Stephen Zhou
1811a855ab chore: update vinext, agentation, remove Prism in lexical (#33142) 2026-03-09 11:40:04 +08:00
Jiaquan Yi
322cd37de1 fix: handle backslash path separators in DOCX ZIP entries exported on…(#33129) (#33131)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-09 10:49:42 +08:00
hjlarry
f90e0d781a feat: add metrics to clean workflow run task 2026-03-09 09:48:24 +08:00
35 changed files with 2662 additions and 579 deletions

View File

@@ -25,6 +25,10 @@ updates:
interval: "weekly"
open-pull-requests-limit: 2
groups:
lexical:
patterns:
- "lexical"
- "@lexical/*"
storybook:
patterns:
- "storybook"
@@ -33,5 +37,7 @@ updates:
patterns:
- "*"
exclude-patterns:
- "lexical"
- "@lexical/*"
- "storybook"
- "@storybook/*"

View File

@@ -30,6 +30,7 @@ from extensions.ext_redis import redis_client
from extensions.ext_storage import storage
from extensions.storage.opendal_storage import OpenDALStorage
from extensions.storage.storage_type import StorageType
from libs.datetime_utils import naive_utc_now
from libs.db_migration_lock import DbMigrationAutoRenewLock
from libs.helper import email as email_validate
from libs.password import hash_password, password_pattern, valid_password
@@ -936,6 +937,12 @@ def clear_free_plan_tenant_expired_logs(days: int, batch: int, tenant_ids: list[
is_flag=True,
help="Preview cleanup results without deleting any workflow run data.",
)
@click.option(
"--task-label",
default="daily",
show_default=True,
help="Stable label value used to distinguish multiple cleanup CronJobs in metrics.",
)
def clean_workflow_runs(
before_days: int,
batch_size: int,
@@ -944,10 +951,13 @@ def clean_workflow_runs(
start_from: datetime.datetime | None,
end_before: datetime.datetime | None,
dry_run: bool,
task_label: str,
):
"""
Clean workflow runs and related workflow data for free tenants.
"""
from extensions.otel.runtime import flush_telemetry
if (start_from is None) ^ (end_before is None):
raise click.UsageError("--start-from and --end-before must be provided together.")
@@ -967,13 +977,17 @@ def clean_workflow_runs(
start_time = datetime.datetime.now(datetime.UTC)
click.echo(click.style(f"Starting workflow run cleanup at {start_time.isoformat()}.", fg="white"))
WorkflowRunCleanup(
days=before_days,
batch_size=batch_size,
start_from=start_from,
end_before=end_before,
dry_run=dry_run,
).run()
try:
WorkflowRunCleanup(
days=before_days,
batch_size=batch_size,
start_from=start_from,
end_before=end_before,
dry_run=dry_run,
task_label=task_label,
).run()
finally:
flush_telemetry()
end_time = datetime.datetime.now(datetime.UTC)
elapsed = end_time - start_time
@@ -2598,15 +2612,29 @@ def migrate_oss(
@click.option(
"--start-from",
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
required=True,
required=False,
default=None,
help="Lower bound (inclusive) for created_at.",
)
@click.option(
"--end-before",
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
required=True,
required=False,
default=None,
help="Upper bound (exclusive) for created_at.",
)
@click.option(
"--from-days-ago",
type=int,
default=None,
help="Relative lower bound in days ago (inclusive). Must be used with --before-days.",
)
@click.option(
"--before-days",
type=int,
default=None,
help="Relative upper bound in days ago (exclusive). Required for relative mode.",
)
@click.option("--batch-size", default=1000, show_default=True, help="Batch size for selecting messages.")
@click.option(
"--graceful-period",
@@ -2615,33 +2643,99 @@ def migrate_oss(
help="Graceful period in days after subscription expiration, will be ignored when billing is disabled.",
)
@click.option("--dry-run", is_flag=True, default=False, help="Show messages logs would be cleaned without deleting")
@click.option(
"--task-label",
default="daily",
show_default=True,
help="Stable label value used to distinguish multiple cleanup CronJobs in metrics.",
)
def clean_expired_messages(
batch_size: int,
graceful_period: int,
start_from: datetime.datetime,
end_before: datetime.datetime,
start_from: datetime.datetime | None,
end_before: datetime.datetime | None,
from_days_ago: int | None,
before_days: int | None,
dry_run: bool,
task_label: str,
):
"""
Clean expired messages and related data for tenants based on clean policy.
"""
from extensions.otel.runtime import flush_telemetry
click.echo(click.style("clean_messages: start clean messages.", fg="green"))
start_at = time.perf_counter()
try:
abs_mode = start_from is not None and end_before is not None
rel_mode = before_days is not None
if abs_mode and rel_mode:
raise click.UsageError(
"Options are mutually exclusive: use either (--start-from,--end-before) "
"or (--from-days-ago,--before-days)."
)
if from_days_ago is not None and before_days is None:
raise click.UsageError("--from-days-ago must be used together with --before-days.")
if (start_from is None) ^ (end_before is None):
raise click.UsageError("Both --start-from and --end-before are required when using absolute time range.")
if not abs_mode and not rel_mode:
raise click.UsageError(
"You must provide either (--start-from,--end-before) or (--before-days [--from-days-ago])."
)
if rel_mode:
assert before_days is not None
if before_days < 0:
raise click.UsageError("--before-days must be >= 0.")
if from_days_ago is not None:
if from_days_ago < 0:
raise click.UsageError("--from-days-ago must be >= 0.")
if from_days_ago <= before_days:
raise click.UsageError("--from-days-ago must be greater than --before-days.")
# Create policy based on billing configuration
# NOTE: graceful_period will be ignored when billing is disabled.
policy = create_message_clean_policy(graceful_period_days=graceful_period)
# Create and run the cleanup service
service = MessagesCleanService.from_time_range(
policy=policy,
start_from=start_from,
end_before=end_before,
batch_size=batch_size,
dry_run=dry_run,
)
if abs_mode:
assert start_from is not None
assert end_before is not None
service = MessagesCleanService.from_time_range(
policy=policy,
start_from=start_from,
end_before=end_before,
batch_size=batch_size,
dry_run=dry_run,
task_label=task_label,
)
elif from_days_ago is None:
assert before_days is not None
service = MessagesCleanService.from_days(
policy=policy,
days=before_days,
batch_size=batch_size,
dry_run=dry_run,
task_label=task_label,
)
else:
assert before_days is not None
assert from_days_ago is not None
now = naive_utc_now()
service = MessagesCleanService.from_time_range(
policy=policy,
start_from=now - datetime.timedelta(days=from_days_ago),
end_before=now - datetime.timedelta(days=before_days),
batch_size=batch_size,
dry_run=dry_run,
task_label=task_label,
)
stats = service.run()
end_at = time.perf_counter()
@@ -2666,6 +2760,8 @@ def clean_expired_messages(
)
)
raise
finally:
flush_telemetry()
click.echo(click.style("messages cleanup completed.", fg="green"))

View File

@@ -37,6 +37,7 @@ VARIABLE_TO_PARAMETER_TYPE_MAPPING = {
VariableEntityType.CHECKBOX: ToolParameter.ToolParameterType.BOOLEAN,
VariableEntityType.FILE: ToolParameter.ToolParameterType.FILE,
VariableEntityType.FILE_LIST: ToolParameter.ToolParameterType.FILES,
VariableEntityType.JSON_OBJECT: ToolParameter.ToolParameterType.OBJECT,
}

View File

@@ -4,6 +4,7 @@ import json
import logging
import os
import tempfile
import zipfile
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any
@@ -82,8 +83,18 @@ class DocumentExtractorNode(Node[DocumentExtractorNodeData]):
value = variable.value
inputs = {"variable_selector": variable_selector}
if isinstance(value, list):
value = list(filter(lambda x: x, value))
process_data = {"documents": value if isinstance(value, list) else [value]}
if not value:
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
inputs=inputs,
process_data=process_data,
outputs={"text": ArrayStringSegment(value=[])},
)
try:
if isinstance(value, list):
extracted_text_list = [
@@ -111,6 +122,7 @@ class DocumentExtractorNode(Node[DocumentExtractorNodeData]):
else:
raise DocumentExtractorError(f"Unsupported variable type: {type(value)}")
except DocumentExtractorError as e:
logger.warning(e, exc_info=True)
return NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
error=str(e),
@@ -385,6 +397,32 @@ def parser_docx_part(block, doc: Document, content_items, i):
content_items.append((i, "table", Table(block, doc)))
def _normalize_docx_zip(file_content: bytes) -> bytes:
"""
Some DOCX files (e.g. exported by Evernote on Windows) are malformed:
ZIP entry names use backslash (\\) as path separator instead of the forward
slash (/) required by both the ZIP spec and OOXML. On Linux/Mac the entry
"word\\document.xml" is never found when python-docx looks for
"word/document.xml", which triggers a KeyError about a missing relationship.
This function rewrites the ZIP in-memory, normalizing all entry names to
use forward slashes without touching any actual document content.
"""
try:
with zipfile.ZipFile(io.BytesIO(file_content), "r") as zin:
out_buf = io.BytesIO()
with zipfile.ZipFile(out_buf, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
data = zin.read(item.filename)
# Normalize backslash path separators to forward slash
item.filename = item.filename.replace("\\", "/")
zout.writestr(item, data)
return out_buf.getvalue()
except zipfile.BadZipFile:
# Not a valid zip — return as-is and let python-docx report the real error
return file_content
def _extract_text_from_docx(file_content: bytes) -> str:
"""
Extract text from a DOCX file.
@@ -392,7 +430,15 @@ def _extract_text_from_docx(file_content: bytes) -> str:
"""
try:
doc_file = io.BytesIO(file_content)
doc = docx.Document(doc_file)
try:
doc = docx.Document(doc_file)
except Exception as e:
logger.warning("Failed to parse DOCX, attempting to normalize ZIP entry paths: %s", e)
# Some DOCX files exported by tools like Evernote on Windows use
# backslash path separators in ZIP entries and/or single-quoted XML
# attributes, both of which break python-docx on Linux. Normalize and retry.
file_content = _normalize_docx_zip(file_content)
doc = docx.Document(io.BytesIO(file_content))
text = []
# Keep track of paragraph and table positions

View File

@@ -23,7 +23,11 @@ from dify_graph.variables import (
)
from dify_graph.variables.segments import ArrayObjectSegment
from .entities import KnowledgeRetrievalNodeData
from .entities import (
Condition,
KnowledgeRetrievalNodeData,
MetadataFilteringCondition,
)
from .exc import (
KnowledgeRetrievalNodeError,
RateLimitExceededError,
@@ -171,6 +175,12 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
if node_data.metadata_filtering_mode is not None:
metadata_filtering_mode = node_data.metadata_filtering_mode
resolved_metadata_conditions = (
self._resolve_metadata_filtering_conditions(node_data.metadata_filtering_conditions)
if node_data.metadata_filtering_conditions
else None
)
if str(node_data.retrieval_mode) == DatasetRetrieveConfigEntity.RetrieveStrategy.SINGLE and query:
# fetch model config
if node_data.single_retrieval_config is None:
@@ -189,7 +199,7 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
model_mode=model.mode,
model_name=model.name,
metadata_model_config=node_data.metadata_model_config,
metadata_filtering_conditions=node_data.metadata_filtering_conditions,
metadata_filtering_conditions=resolved_metadata_conditions,
metadata_filtering_mode=metadata_filtering_mode,
query=query,
)
@@ -247,7 +257,7 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
weights=weights,
reranking_enable=node_data.multiple_retrieval_config.reranking_enable,
metadata_model_config=node_data.metadata_model_config,
metadata_filtering_conditions=node_data.metadata_filtering_conditions,
metadata_filtering_conditions=resolved_metadata_conditions,
metadata_filtering_mode=metadata_filtering_mode,
attachment_ids=[attachment.related_id for attachment in attachments] if attachments else None,
)
@@ -256,6 +266,48 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
usage = self._rag_retrieval.llm_usage
return retrieval_resource_list, usage
def _resolve_metadata_filtering_conditions(
self, conditions: MetadataFilteringCondition
) -> MetadataFilteringCondition:
if conditions.conditions is None:
return MetadataFilteringCondition(
logical_operator=conditions.logical_operator,
conditions=None,
)
variable_pool = self.graph_runtime_state.variable_pool
resolved_conditions: list[Condition] = []
for cond in conditions.conditions or []:
value = cond.value
if isinstance(value, str):
segment_group = variable_pool.convert_template(value)
if len(segment_group.value) == 1:
resolved_value = segment_group.value[0].to_object()
else:
resolved_value = segment_group.text
elif isinstance(value, Sequence) and all(isinstance(v, str) for v in value):
resolved_values = []
for v in value: # type: ignore
segment_group = variable_pool.convert_template(v)
if len(segment_group.value) == 1:
resolved_values.append(segment_group.value[0].to_object())
else:
resolved_values.append(segment_group.text)
resolved_value = resolved_values
else:
resolved_value = value
resolved_conditions.append(
Condition(
name=cond.name,
comparison_operator=cond.comparison_operator,
value=resolved_value,
)
)
return MetadataFilteringCondition(
logical_operator=conditions.logical_operator or "and",
conditions=resolved_conditions,
)
@classmethod
def _extract_variable_selector_to_variable_mapping(
cls,

View File

@@ -5,7 +5,7 @@ from typing import Union
from celery.signals import worker_init
from flask_login import user_loaded_from_request, user_logged_in
from opentelemetry import trace
from opentelemetry import metrics, trace
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.b3 import B3Format
from opentelemetry.propagators.composite import CompositePropagator
@@ -31,9 +31,29 @@ def setup_context_propagation() -> None:
def shutdown_tracer() -> None:
flush_telemetry()
def flush_telemetry() -> None:
"""
Best-effort flush for telemetry providers.
This is mainly used by short-lived command processes (e.g. Kubernetes CronJob)
so counters/histograms are exported before the process exits.
"""
provider = trace.get_tracer_provider()
if hasattr(provider, "force_flush"):
provider.force_flush()
try:
provider.force_flush()
except Exception:
logger.exception("otel: failed to flush trace provider")
metric_provider = metrics.get_meter_provider()
if hasattr(metric_provider, "force_flush"):
try:
metric_provider.force_flush()
except Exception:
logger.exception("otel: failed to flush metric provider")
def is_celery_worker():

View File

@@ -1,17 +1,18 @@
import datetime
import logging
import os
import random
import time
from collections.abc import Sequence
from typing import cast
from typing import TYPE_CHECKING, cast
import sqlalchemy as sa
from sqlalchemy import delete, select, tuple_
from sqlalchemy.engine import CursorResult
from sqlalchemy.orm import Session
from configs import dify_config
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from models.model import (
App,
AppAnnotationHitHistory,
@@ -32,6 +33,128 @@ from services.retention.conversation.messages_clean_policy import (
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from opentelemetry.metrics import Counter, Histogram
class MessagesCleanupMetrics:
"""
Records low-cardinality OpenTelemetry metrics for expired message cleanup jobs.
We keep labels stable (dry_run/window_mode/task_label/status) so these metrics remain
dashboard-friendly for long-running CronJob executions.
"""
_job_runs_total: "Counter | None"
_batches_total: "Counter | None"
_messages_scanned_total: "Counter | None"
_messages_filtered_total: "Counter | None"
_messages_deleted_total: "Counter | None"
_job_duration_seconds: "Histogram | None"
_batch_duration_seconds: "Histogram | None"
_base_attributes: dict[str, str]
def __init__(self, *, dry_run: bool, has_window: bool, task_label: str) -> None:
self._job_runs_total = None
self._batches_total = None
self._messages_scanned_total = None
self._messages_filtered_total = None
self._messages_deleted_total = None
self._job_duration_seconds = None
self._batch_duration_seconds = None
self._base_attributes = {
"job_name": "messages_cleanup",
"dry_run": str(dry_run).lower(),
"window_mode": "between" if has_window else "before_cutoff",
"task_label": task_label,
}
self._init_instruments()
def _init_instruments(self) -> None:
try:
from opentelemetry.metrics import get_meter
meter = get_meter("messages_cleanup", version=dify_config.project.version)
self._job_runs_total = meter.create_counter(
"messages_cleanup_jobs_total",
description="Total number of expired message cleanup jobs by status.",
unit="{job}",
)
self._batches_total = meter.create_counter(
"messages_cleanup_batches_total",
description="Total number of message cleanup batches processed.",
unit="{batch}",
)
self._messages_scanned_total = meter.create_counter(
"messages_cleanup_scanned_messages_total",
description="Total messages scanned by cleanup jobs.",
unit="{message}",
)
self._messages_filtered_total = meter.create_counter(
"messages_cleanup_filtered_messages_total",
description="Total messages selected by cleanup policy.",
unit="{message}",
)
self._messages_deleted_total = meter.create_counter(
"messages_cleanup_deleted_messages_total",
description="Total messages deleted by cleanup jobs.",
unit="{message}",
)
self._job_duration_seconds = meter.create_histogram(
"messages_cleanup_job_duration_seconds",
description="Duration of expired message cleanup jobs in seconds.",
unit="s",
)
self._batch_duration_seconds = meter.create_histogram(
"messages_cleanup_batch_duration_seconds",
description="Duration of expired message cleanup batch processing in seconds.",
unit="s",
)
except Exception:
logger.exception("messages_cleanup_metrics: failed to initialize instruments")
def _attrs(self, **extra: str) -> dict[str, str]:
return {**self._base_attributes, **extra}
@staticmethod
def _add(counter: "Counter | None", value: int, attributes: dict[str, str]) -> None:
if not counter or value <= 0:
return
try:
counter.add(value, attributes)
except Exception:
logger.exception("messages_cleanup_metrics: failed to add counter value")
@staticmethod
def _record(histogram: "Histogram | None", value: float, attributes: dict[str, str]) -> None:
if not histogram:
return
try:
histogram.record(value, attributes)
except Exception:
logger.exception("messages_cleanup_metrics: failed to record histogram value")
def record_batch(
self,
*,
scanned_messages: int,
filtered_messages: int,
deleted_messages: int,
batch_duration_seconds: float,
) -> None:
attributes = self._attrs()
self._add(self._batches_total, 1, attributes)
self._add(self._messages_scanned_total, scanned_messages, attributes)
self._add(self._messages_filtered_total, filtered_messages, attributes)
self._add(self._messages_deleted_total, deleted_messages, attributes)
self._record(self._batch_duration_seconds, batch_duration_seconds, attributes)
def record_completion(self, *, status: str, job_duration_seconds: float) -> None:
attributes = self._attrs(status=status)
self._add(self._job_runs_total, 1, attributes)
self._record(self._job_duration_seconds, job_duration_seconds, attributes)
class MessagesCleanService:
"""
Service for cleaning expired messages based on retention policies.
@@ -47,6 +170,7 @@ class MessagesCleanService:
start_from: datetime.datetime | None = None,
batch_size: int = 1000,
dry_run: bool = False,
task_label: str = "daily",
) -> None:
"""
Initialize the service with cleanup parameters.
@@ -57,12 +181,20 @@ class MessagesCleanService:
start_from: Optional start time (inclusive) of the range
batch_size: Number of messages to process per batch
dry_run: Whether to perform a dry run (no actual deletion)
task_label: Stable task label to distinguish multiple cleanup CronJobs
"""
self._policy = policy
self._end_before = end_before
self._start_from = start_from
self._batch_size = batch_size
self._dry_run = dry_run
normalized_task_label = task_label.strip()
self._task_label = normalized_task_label or "daily"
self._metrics = MessagesCleanupMetrics(
dry_run=dry_run,
has_window=bool(start_from),
task_label=self._task_label,
)
@classmethod
def from_time_range(
@@ -72,6 +204,7 @@ class MessagesCleanService:
end_before: datetime.datetime,
batch_size: int = 1000,
dry_run: bool = False,
task_label: str = "daily",
) -> "MessagesCleanService":
"""
Create a service instance for cleaning messages within a specific time range.
@@ -84,6 +217,7 @@ class MessagesCleanService:
end_before: End time (exclusive) of the range
batch_size: Number of messages to process per batch
dry_run: Whether to perform a dry run (no actual deletion)
task_label: Stable task label to distinguish multiple cleanup CronJobs
Returns:
MessagesCleanService instance
@@ -111,6 +245,7 @@ class MessagesCleanService:
start_from=start_from,
batch_size=batch_size,
dry_run=dry_run,
task_label=task_label,
)
@classmethod
@@ -120,6 +255,7 @@ class MessagesCleanService:
days: int = 30,
batch_size: int = 1000,
dry_run: bool = False,
task_label: str = "daily",
) -> "MessagesCleanService":
"""
Create a service instance for cleaning messages older than specified days.
@@ -129,6 +265,7 @@ class MessagesCleanService:
days: Number of days to look back from now
batch_size: Number of messages to process per batch
dry_run: Whether to perform a dry run (no actual deletion)
task_label: Stable task label to distinguish multiple cleanup CronJobs
Returns:
MessagesCleanService instance
@@ -142,7 +279,7 @@ class MessagesCleanService:
if batch_size <= 0:
raise ValueError(f"batch_size ({batch_size}) must be greater than 0")
end_before = datetime.datetime.now() - datetime.timedelta(days=days)
end_before = naive_utc_now() - datetime.timedelta(days=days)
logger.info(
"clean_messages: days=%s, end_before=%s, batch_size=%s, policy=%s",
@@ -152,7 +289,14 @@ class MessagesCleanService:
policy.__class__.__name__,
)
return cls(policy=policy, end_before=end_before, start_from=None, batch_size=batch_size, dry_run=dry_run)
return cls(
policy=policy,
end_before=end_before,
start_from=None,
batch_size=batch_size,
dry_run=dry_run,
task_label=task_label,
)
def run(self) -> dict[str, int]:
"""
@@ -161,7 +305,18 @@ class MessagesCleanService:
Returns:
Dict with statistics: batches, filtered_messages, total_deleted
"""
return self._clean_messages_by_time_range()
status = "success"
run_start = time.monotonic()
try:
return self._clean_messages_by_time_range()
except Exception:
status = "failed"
raise
finally:
self._metrics.record_completion(
status=status,
job_duration_seconds=time.monotonic() - run_start,
)
def _clean_messages_by_time_range(self) -> dict[str, int]:
"""
@@ -196,11 +351,14 @@ class MessagesCleanService:
self._end_before,
)
max_batch_interval_ms = int(os.environ.get("SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL", 200))
max_batch_interval_ms = dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL
while True:
stats["batches"] += 1
batch_start = time.monotonic()
batch_scanned_messages = 0
batch_filtered_messages = 0
batch_deleted_messages = 0
# Step 1: Fetch a batch of messages using cursor
with Session(db.engine, expire_on_commit=False) as session:
@@ -239,9 +397,16 @@ class MessagesCleanService:
# Track total messages fetched across all batches
stats["total_messages"] += len(messages)
batch_scanned_messages = len(messages)
if not messages:
logger.info("clean_messages (batch %s): no more messages to process", stats["batches"])
self._metrics.record_batch(
scanned_messages=batch_scanned_messages,
filtered_messages=batch_filtered_messages,
deleted_messages=batch_deleted_messages,
batch_duration_seconds=time.monotonic() - batch_start,
)
break
# Update cursor to the last message's (created_at, id)
@@ -267,6 +432,12 @@ class MessagesCleanService:
if not apps:
logger.info("clean_messages (batch %s): no apps found, skip", stats["batches"])
self._metrics.record_batch(
scanned_messages=batch_scanned_messages,
filtered_messages=batch_filtered_messages,
deleted_messages=batch_deleted_messages,
batch_duration_seconds=time.monotonic() - batch_start,
)
continue
# Build app_id -> tenant_id mapping
@@ -285,9 +456,16 @@ class MessagesCleanService:
if not message_ids_to_delete:
logger.info("clean_messages (batch %s): no messages to delete, skip", stats["batches"])
self._metrics.record_batch(
scanned_messages=batch_scanned_messages,
filtered_messages=batch_filtered_messages,
deleted_messages=batch_deleted_messages,
batch_duration_seconds=time.monotonic() - batch_start,
)
continue
stats["filtered_messages"] += len(message_ids_to_delete)
batch_filtered_messages = len(message_ids_to_delete)
# Step 4: Batch delete messages and their relations
if not self._dry_run:
@@ -308,6 +486,7 @@ class MessagesCleanService:
commit_ms = int((time.monotonic() - commit_start) * 1000)
stats["total_deleted"] += messages_deleted
batch_deleted_messages = messages_deleted
logger.info(
"clean_messages (batch %s): processed %s messages, deleted %s messages",
@@ -342,6 +521,13 @@ class MessagesCleanService:
for msg_id in sampled_ids:
logger.info("clean_messages (batch %s, dry_run) sample: message_id=%s", stats["batches"], msg_id)
self._metrics.record_batch(
scanned_messages=batch_scanned_messages,
filtered_messages=batch_filtered_messages,
deleted_messages=batch_deleted_messages,
batch_duration_seconds=time.monotonic() - batch_start,
)
logger.info(
"clean_messages completed: total batches: %s, total messages: %s, filtered messages: %s, total deleted: %s",
stats["batches"],

View File

@@ -1,9 +1,9 @@
import datetime
import logging
import os
import random
import time
from collections.abc import Iterable, Sequence
from typing import TYPE_CHECKING
import click
from sqlalchemy.orm import Session, sessionmaker
@@ -20,6 +20,156 @@ from services.billing_service import BillingService, SubscriptionPlan
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from opentelemetry.metrics import Counter, Histogram
class WorkflowRunCleanupMetrics:
"""
Records low-cardinality OpenTelemetry metrics for workflow run cleanup jobs.
Metrics are emitted with stable labels only (dry_run/window_mode/task_label/status)
to keep dashboard and alert cardinality predictable in production clusters.
"""
_job_runs_total: "Counter | None"
_batches_total: "Counter | None"
_runs_scanned_total: "Counter | None"
_runs_targeted_total: "Counter | None"
_runs_deleted_total: "Counter | None"
_runs_skipped_total: "Counter | None"
_related_records_total: "Counter | None"
_job_duration_seconds: "Histogram | None"
_batch_duration_seconds: "Histogram | None"
_base_attributes: dict[str, str]
def __init__(self, *, dry_run: bool, has_window: bool, task_label: str) -> None:
self._job_runs_total = None
self._batches_total = None
self._runs_scanned_total = None
self._runs_targeted_total = None
self._runs_deleted_total = None
self._runs_skipped_total = None
self._related_records_total = None
self._job_duration_seconds = None
self._batch_duration_seconds = None
self._base_attributes = {
"job_name": "workflow_run_cleanup",
"dry_run": str(dry_run).lower(),
"window_mode": "between" if has_window else "before_cutoff",
"task_label": task_label,
}
self._init_instruments()
def _init_instruments(self) -> None:
try:
from opentelemetry.metrics import get_meter
meter = get_meter("workflow_run_cleanup", version=dify_config.project.version)
self._job_runs_total = meter.create_counter(
"workflow_run_cleanup_jobs_total",
description="Total number of workflow run cleanup jobs by status.",
unit="{job}",
)
self._batches_total = meter.create_counter(
"workflow_run_cleanup_batches_total",
description="Total number of processed cleanup batches.",
unit="{batch}",
)
self._runs_scanned_total = meter.create_counter(
"workflow_run_cleanup_scanned_runs_total",
description="Total workflow runs scanned by cleanup jobs.",
unit="{run}",
)
self._runs_targeted_total = meter.create_counter(
"workflow_run_cleanup_targeted_runs_total",
description="Total workflow runs targeted by cleanup policy.",
unit="{run}",
)
self._runs_deleted_total = meter.create_counter(
"workflow_run_cleanup_deleted_runs_total",
description="Total workflow runs deleted by cleanup jobs.",
unit="{run}",
)
self._runs_skipped_total = meter.create_counter(
"workflow_run_cleanup_skipped_runs_total",
description="Total workflow runs skipped because tenant is paid/unknown.",
unit="{run}",
)
self._related_records_total = meter.create_counter(
"workflow_run_cleanup_related_records_total",
description="Total related records processed by cleanup jobs.",
unit="{record}",
)
self._job_duration_seconds = meter.create_histogram(
"workflow_run_cleanup_job_duration_seconds",
description="Duration of workflow run cleanup jobs in seconds.",
unit="s",
)
self._batch_duration_seconds = meter.create_histogram(
"workflow_run_cleanup_batch_duration_seconds",
description="Duration of workflow run cleanup batch processing in seconds.",
unit="s",
)
except Exception:
logger.exception("workflow_run_cleanup_metrics: failed to initialize instruments")
def _attrs(self, **extra: str) -> dict[str, str]:
return {**self._base_attributes, **extra}
@staticmethod
def _add(counter: "Counter | None", value: int, attributes: dict[str, str]) -> None:
if not counter or value <= 0:
return
try:
counter.add(value, attributes)
except Exception:
logger.exception("workflow_run_cleanup_metrics: failed to add counter value")
@staticmethod
def _record(histogram: "Histogram | None", value: float, attributes: dict[str, str]) -> None:
if not histogram:
return
try:
histogram.record(value, attributes)
except Exception:
logger.exception("workflow_run_cleanup_metrics: failed to record histogram value")
def record_batch(
self,
*,
batch_rows: int,
targeted_runs: int,
skipped_runs: int,
deleted_runs: int,
related_counts: dict[str, int] | None,
related_action: str | None,
batch_duration_seconds: float,
) -> None:
attributes = self._attrs()
self._add(self._batches_total, 1, attributes)
self._add(self._runs_scanned_total, batch_rows, attributes)
self._add(self._runs_targeted_total, targeted_runs, attributes)
self._add(self._runs_skipped_total, skipped_runs, attributes)
self._add(self._runs_deleted_total, deleted_runs, attributes)
self._record(self._batch_duration_seconds, batch_duration_seconds, attributes)
if not related_counts or not related_action:
return
for record_type, count in related_counts.items():
self._add(
self._related_records_total,
count,
self._attrs(action=related_action, record_type=record_type),
)
def record_completion(self, *, status: str, job_duration_seconds: float) -> None:
attributes = self._attrs(status=status)
self._add(self._job_runs_total, 1, attributes)
self._record(self._job_duration_seconds, job_duration_seconds, attributes)
class WorkflowRunCleanup:
def __init__(
self,
@@ -29,6 +179,7 @@ class WorkflowRunCleanup:
end_before: datetime.datetime | None = None,
workflow_run_repo: APIWorkflowRunRepository | None = None,
dry_run: bool = False,
task_label: str = "daily",
):
if (start_from is None) ^ (end_before is None):
raise ValueError("start_from and end_before must be both set or both omitted.")
@@ -46,6 +197,13 @@ class WorkflowRunCleanup:
self.batch_size = batch_size
self._cleanup_whitelist: set[str] | None = None
self.dry_run = dry_run
normalized_task_label = task_label.strip()
self.task_label = normalized_task_label or "daily"
self._metrics = WorkflowRunCleanupMetrics(
dry_run=dry_run,
has_window=bool(start_from),
task_label=self.task_label,
)
self.free_plan_grace_period_days = dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD
self.workflow_run_repo: APIWorkflowRunRepository
if workflow_run_repo:
@@ -74,153 +232,193 @@ class WorkflowRunCleanup:
related_totals = self._empty_related_counts() if self.dry_run else None
batch_index = 0
last_seen: tuple[datetime.datetime, str] | None = None
status = "success"
run_start = time.monotonic()
max_batch_interval_ms = dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL
max_batch_interval_ms = int(os.environ.get("SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL", 200))
try:
while True:
batch_start = time.monotonic()
while True:
batch_start = time.monotonic()
fetch_start = time.monotonic()
run_rows = self.workflow_run_repo.get_runs_batch_by_time_range(
start_from=self.window_start,
end_before=self.window_end,
last_seen=last_seen,
batch_size=self.batch_size,
)
if not run_rows:
logger.info("workflow_run_cleanup (batch #%s): no more rows to process", batch_index + 1)
break
batch_index += 1
last_seen = (run_rows[-1].created_at, run_rows[-1].id)
logger.info(
"workflow_run_cleanup (batch #%s): fetched %s rows in %sms",
batch_index,
len(run_rows),
int((time.monotonic() - fetch_start) * 1000),
)
tenant_ids = {row.tenant_id for row in run_rows}
filter_start = time.monotonic()
free_tenants = self._filter_free_tenants(tenant_ids)
logger.info(
"workflow_run_cleanup (batch #%s): filtered %s free tenants from %s tenants in %sms",
batch_index,
len(free_tenants),
len(tenant_ids),
int((time.monotonic() - filter_start) * 1000),
)
free_runs = [row for row in run_rows if row.tenant_id in free_tenants]
paid_or_skipped = len(run_rows) - len(free_runs)
if not free_runs:
skipped_message = (
f"[batch #{batch_index}] skipped (no sandbox runs in batch, {paid_or_skipped} paid/unknown)"
fetch_start = time.monotonic()
run_rows = self.workflow_run_repo.get_runs_batch_by_time_range(
start_from=self.window_start,
end_before=self.window_end,
last_seen=last_seen,
batch_size=self.batch_size,
)
click.echo(
click.style(
skipped_message,
fg="yellow",
)
)
continue
if not run_rows:
logger.info("workflow_run_cleanup (batch #%s): no more rows to process", batch_index + 1)
break
total_runs_targeted += len(free_runs)
if self.dry_run:
count_start = time.monotonic()
batch_counts = self.workflow_run_repo.count_runs_with_related(
free_runs,
count_node_executions=self._count_node_executions,
count_trigger_logs=self._count_trigger_logs,
)
batch_index += 1
last_seen = (run_rows[-1].created_at, run_rows[-1].id)
logger.info(
"workflow_run_cleanup (batch #%s, dry_run): counted related records in %sms",
"workflow_run_cleanup (batch #%s): fetched %s rows in %sms",
batch_index,
int((time.monotonic() - count_start) * 1000),
len(run_rows),
int((time.monotonic() - fetch_start) * 1000),
)
if related_totals is not None:
for key in related_totals:
related_totals[key] += batch_counts.get(key, 0)
sample_ids = ", ".join(run.id for run in free_runs[:5])
tenant_ids = {row.tenant_id for row in run_rows}
filter_start = time.monotonic()
free_tenants = self._filter_free_tenants(tenant_ids)
logger.info(
"workflow_run_cleanup (batch #%s): filtered %s free tenants from %s tenants in %sms",
batch_index,
len(free_tenants),
len(tenant_ids),
int((time.monotonic() - filter_start) * 1000),
)
free_runs = [row for row in run_rows if row.tenant_id in free_tenants]
paid_or_skipped = len(run_rows) - len(free_runs)
if not free_runs:
skipped_message = (
f"[batch #{batch_index}] skipped (no sandbox runs in batch, {paid_or_skipped} paid/unknown)"
)
click.echo(
click.style(
skipped_message,
fg="yellow",
)
)
self._metrics.record_batch(
batch_rows=len(run_rows),
targeted_runs=0,
skipped_runs=paid_or_skipped,
deleted_runs=0,
related_counts=None,
related_action=None,
batch_duration_seconds=time.monotonic() - batch_start,
)
continue
total_runs_targeted += len(free_runs)
if self.dry_run:
count_start = time.monotonic()
batch_counts = self.workflow_run_repo.count_runs_with_related(
free_runs,
count_node_executions=self._count_node_executions,
count_trigger_logs=self._count_trigger_logs,
)
logger.info(
"workflow_run_cleanup (batch #%s, dry_run): counted related records in %sms",
batch_index,
int((time.monotonic() - count_start) * 1000),
)
if related_totals is not None:
for key in related_totals:
related_totals[key] += batch_counts.get(key, 0)
sample_ids = ", ".join(run.id for run in free_runs[:5])
click.echo(
click.style(
f"[batch #{batch_index}] would delete {len(free_runs)} runs "
f"(sample ids: {sample_ids}) and skip {paid_or_skipped} paid/unknown",
fg="yellow",
)
)
logger.info(
"workflow_run_cleanup (batch #%s, dry_run): batch total %sms",
batch_index,
int((time.monotonic() - batch_start) * 1000),
)
self._metrics.record_batch(
batch_rows=len(run_rows),
targeted_runs=len(free_runs),
skipped_runs=paid_or_skipped,
deleted_runs=0,
related_counts={key: batch_counts.get(key, 0) for key in self._empty_related_counts()},
related_action="would_delete",
batch_duration_seconds=time.monotonic() - batch_start,
)
continue
try:
delete_start = time.monotonic()
counts = self.workflow_run_repo.delete_runs_with_related(
free_runs,
delete_node_executions=self._delete_node_executions,
delete_trigger_logs=self._delete_trigger_logs,
)
delete_ms = int((time.monotonic() - delete_start) * 1000)
except Exception:
logger.exception("Failed to delete workflow runs batch ending at %s", last_seen[0])
raise
total_runs_deleted += counts["runs"]
click.echo(
click.style(
f"[batch #{batch_index}] would delete {len(free_runs)} runs "
f"(sample ids: {sample_ids}) and skip {paid_or_skipped} paid/unknown",
fg="yellow",
f"[batch #{batch_index}] deleted runs: {counts['runs']} "
f"(nodes {counts['node_executions']}, offloads {counts['offloads']}, "
f"app_logs {counts['app_logs']}, trigger_logs {counts['trigger_logs']}, "
f"pauses {counts['pauses']}, pause_reasons {counts['pause_reasons']}); "
f"skipped {paid_or_skipped} paid/unknown",
fg="green",
)
)
logger.info(
"workflow_run_cleanup (batch #%s, dry_run): batch total %sms",
"workflow_run_cleanup (batch #%s): delete %sms, batch total %sms",
batch_index,
delete_ms,
int((time.monotonic() - batch_start) * 1000),
)
continue
try:
delete_start = time.monotonic()
counts = self.workflow_run_repo.delete_runs_with_related(
free_runs,
delete_node_executions=self._delete_node_executions,
delete_trigger_logs=self._delete_trigger_logs,
self._metrics.record_batch(
batch_rows=len(run_rows),
targeted_runs=len(free_runs),
skipped_runs=paid_or_skipped,
deleted_runs=counts["runs"],
related_counts={key: counts.get(key, 0) for key in self._empty_related_counts()},
related_action="deleted",
batch_duration_seconds=time.monotonic() - batch_start,
)
delete_ms = int((time.monotonic() - delete_start) * 1000)
except Exception:
logger.exception("Failed to delete workflow runs batch ending at %s", last_seen[0])
raise
total_runs_deleted += counts["runs"]
click.echo(
click.style(
f"[batch #{batch_index}] deleted runs: {counts['runs']} "
f"(nodes {counts['node_executions']}, offloads {counts['offloads']}, "
f"app_logs {counts['app_logs']}, trigger_logs {counts['trigger_logs']}, "
f"pauses {counts['pauses']}, pause_reasons {counts['pause_reasons']}); "
f"skipped {paid_or_skipped} paid/unknown",
fg="green",
)
)
logger.info(
"workflow_run_cleanup (batch #%s): delete %sms, batch total %sms",
batch_index,
delete_ms,
int((time.monotonic() - batch_start) * 1000),
)
# Random sleep between batches to avoid overwhelming the database
sleep_ms = random.uniform(0, max_batch_interval_ms) # noqa: S311
logger.info("workflow_run_cleanup (batch #%s): sleeping for %.2fms", batch_index, sleep_ms)
time.sleep(sleep_ms / 1000)
# Random sleep between batches to avoid overwhelming the database
sleep_ms = random.uniform(0, max_batch_interval_ms) # noqa: S311
logger.info("workflow_run_cleanup (batch #%s): sleeping for %.2fms", batch_index, sleep_ms)
time.sleep(sleep_ms / 1000)
if self.dry_run:
if self.window_start:
summary_message = (
f"Dry run complete. Would delete {total_runs_targeted} workflow runs "
f"between {self.window_start.isoformat()} and {self.window_end.isoformat()}"
)
if self.dry_run:
if self.window_start:
summary_message = (
f"Dry run complete. Would delete {total_runs_targeted} workflow runs "
f"between {self.window_start.isoformat()} and {self.window_end.isoformat()}"
)
else:
summary_message = (
f"Dry run complete. Would delete {total_runs_targeted} workflow runs "
f"before {self.window_end.isoformat()}"
)
if related_totals is not None:
summary_message = (
f"{summary_message}; related records: {self._format_related_counts(related_totals)}"
)
summary_color = "yellow"
else:
summary_message = (
f"Dry run complete. Would delete {total_runs_targeted} workflow runs "
f"before {self.window_end.isoformat()}"
)
if related_totals is not None:
summary_message = f"{summary_message}; related records: {self._format_related_counts(related_totals)}"
summary_color = "yellow"
else:
if self.window_start:
summary_message = (
f"Cleanup complete. Deleted {total_runs_deleted} workflow runs "
f"between {self.window_start.isoformat()} and {self.window_end.isoformat()}"
)
else:
summary_message = (
f"Cleanup complete. Deleted {total_runs_deleted} workflow runs before {self.window_end.isoformat()}"
)
summary_color = "white"
if self.window_start:
summary_message = (
f"Cleanup complete. Deleted {total_runs_deleted} workflow runs "
f"between {self.window_start.isoformat()} and {self.window_end.isoformat()}"
)
else:
summary_message = (
f"Cleanup complete. Deleted {total_runs_deleted} workflow runs "
f"before {self.window_end.isoformat()}"
)
summary_color = "white"
click.echo(click.style(summary_message, fg=summary_color))
click.echo(click.style(summary_message, fg=summary_color))
except Exception:
status = "failed"
raise
finally:
self._metrics.record_completion(
status=status,
job_duration_seconds=time.monotonic() - run_start,
)
def _filter_free_tenants(self, tenant_ids: Iterable[str]) -> set[str]:
tenant_id_list = list(tenant_ids)

View File

@@ -0,0 +1,188 @@
import datetime
import re
from unittest.mock import MagicMock, patch
import click
import pytest
from commands import clean_expired_messages
def _mock_service() -> MagicMock:
service = MagicMock()
service.run.return_value = {
"batches": 1,
"total_messages": 10,
"filtered_messages": 5,
"total_deleted": 5,
}
return service
def test_absolute_mode_calls_from_time_range():
policy = object()
service = _mock_service()
start_from = datetime.datetime(2024, 1, 1, 0, 0, 0)
end_before = datetime.datetime(2024, 2, 1, 0, 0, 0)
with (
patch("commands.create_message_clean_policy", return_value=policy),
patch("commands.MessagesCleanService.from_time_range", return_value=service) as mock_from_time_range,
patch("commands.MessagesCleanService.from_days") as mock_from_days,
):
clean_expired_messages.callback(
batch_size=200,
graceful_period=21,
start_from=start_from,
end_before=end_before,
from_days_ago=None,
before_days=None,
dry_run=True,
task_label="daily",
)
mock_from_time_range.assert_called_once_with(
policy=policy,
start_from=start_from,
end_before=end_before,
batch_size=200,
dry_run=True,
task_label="daily",
)
mock_from_days.assert_not_called()
def test_relative_mode_before_days_only_calls_from_days():
policy = object()
service = _mock_service()
with (
patch("commands.create_message_clean_policy", return_value=policy),
patch("commands.MessagesCleanService.from_days", return_value=service) as mock_from_days,
patch("commands.MessagesCleanService.from_time_range") as mock_from_time_range,
):
clean_expired_messages.callback(
batch_size=500,
graceful_period=14,
start_from=None,
end_before=None,
from_days_ago=None,
before_days=30,
dry_run=False,
task_label="daily",
)
mock_from_days.assert_called_once_with(
policy=policy,
days=30,
batch_size=500,
dry_run=False,
task_label="daily",
)
mock_from_time_range.assert_not_called()
def test_relative_mode_with_from_days_ago_calls_from_time_range():
policy = object()
service = _mock_service()
fixed_now = datetime.datetime(2024, 8, 20, 12, 0, 0)
with (
patch("commands.create_message_clean_policy", return_value=policy),
patch("commands.MessagesCleanService.from_time_range", return_value=service) as mock_from_time_range,
patch("commands.MessagesCleanService.from_days") as mock_from_days,
patch("commands.naive_utc_now", return_value=fixed_now),
):
clean_expired_messages.callback(
batch_size=1000,
graceful_period=21,
start_from=None,
end_before=None,
from_days_ago=60,
before_days=30,
dry_run=False,
task_label="daily",
)
mock_from_time_range.assert_called_once_with(
policy=policy,
start_from=fixed_now - datetime.timedelta(days=60),
end_before=fixed_now - datetime.timedelta(days=30),
batch_size=1000,
dry_run=False,
task_label="daily",
)
mock_from_days.assert_not_called()
@pytest.mark.parametrize(
("kwargs", "message"),
[
(
{
"start_from": datetime.datetime(2024, 1, 1),
"end_before": datetime.datetime(2024, 2, 1),
"from_days_ago": None,
"before_days": 30,
},
"mutually exclusive",
),
(
{
"start_from": datetime.datetime(2024, 1, 1),
"end_before": None,
"from_days_ago": None,
"before_days": None,
},
"Both --start-from and --end-before are required",
),
(
{
"start_from": None,
"end_before": None,
"from_days_ago": 10,
"before_days": None,
},
"--from-days-ago must be used together with --before-days",
),
(
{
"start_from": None,
"end_before": None,
"from_days_ago": None,
"before_days": -1,
},
"--before-days must be >= 0",
),
(
{
"start_from": None,
"end_before": None,
"from_days_ago": 30,
"before_days": 30,
},
"--from-days-ago must be greater than --before-days",
),
(
{
"start_from": None,
"end_before": None,
"from_days_ago": None,
"before_days": None,
},
"You must provide either (--start-from,--end-before) or (--before-days [--from-days-ago])",
),
],
)
def test_invalid_inputs_raise_usage_error(kwargs: dict, message: str):
with pytest.raises(click.UsageError, match=re.escape(message)):
clean_expired_messages.callback(
batch_size=1000,
graceful_period=21,
start_from=kwargs["start_from"],
end_before=kwargs["end_before"],
from_days_ago=kwargs["from_days_ago"],
before_days=kwargs["before_days"],
dry_run=False,
task_label="daily",
)

View File

@@ -8,7 +8,9 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from dify_graph.enums import WorkflowNodeExecutionStatus
from dify_graph.model_runtime.entities.llm_entities import LLMUsage
from dify_graph.nodes.knowledge_retrieval.entities import (
Condition,
KnowledgeRetrievalNodeData,
MetadataFilteringCondition,
MultipleRetrievalConfig,
RerankingModelConfig,
SingleRetrievalConfig,
@@ -593,3 +595,106 @@ class TestFetchDatasetRetriever:
# Assert
assert version == "1"
def test_resolve_metadata_filtering_conditions_templates(
self,
mock_graph_init_params,
mock_graph_runtime_state,
mock_rag_retrieval,
):
"""_resolve_metadata_filtering_conditions should expand {{#...#}} and keep numbers/None unchanged."""
# Arrange
node_id = str(uuid.uuid4())
config = {
"id": node_id,
"data": {
"title": "Knowledge Retrieval",
"type": "knowledge-retrieval",
"dataset_ids": [str(uuid.uuid4())],
"retrieval_mode": "multiple",
},
}
# Variable in pool used by template
mock_graph_runtime_state.variable_pool.add(["start", "query"], StringSegment(value="readme"))
node = KnowledgeRetrievalNode(
id=node_id,
config=config,
graph_init_params=mock_graph_init_params,
graph_runtime_state=mock_graph_runtime_state,
rag_retrieval=mock_rag_retrieval,
)
conditions = MetadataFilteringCondition(
logical_operator="and",
conditions=[
Condition(name="document_name", comparison_operator="is", value="{{#start.query#}}"),
Condition(name="tags", comparison_operator="in", value=["x", "{{#start.query#}}"]),
Condition(name="year", comparison_operator="=", value=2025),
],
)
# Act
resolved = node._resolve_metadata_filtering_conditions(conditions)
# Assert
assert resolved.logical_operator == "and"
assert resolved.conditions[0].value == "readme"
assert isinstance(resolved.conditions[1].value, list)
assert resolved.conditions[1].value[1] == "readme"
assert resolved.conditions[2].value == 2025
def test_fetch_passes_resolved_metadata_conditions(
self,
mock_graph_init_params,
mock_graph_runtime_state,
mock_rag_retrieval,
):
"""_fetch_dataset_retriever should pass resolved metadata conditions into request."""
# Arrange
query = "hi"
variables = {"query": query}
mock_graph_runtime_state.variable_pool.add(["start", "q"], StringSegment(value="readme"))
node_data = KnowledgeRetrievalNodeData(
title="Knowledge Retrieval",
type="knowledge-retrieval",
dataset_ids=[str(uuid.uuid4())],
retrieval_mode="multiple",
multiple_retrieval_config=MultipleRetrievalConfig(
top_k=4,
score_threshold=0.0,
reranking_mode="reranking_model",
reranking_enable=True,
reranking_model=RerankingModelConfig(provider="cohere", model="rerank-v2"),
),
metadata_filtering_mode="manual",
metadata_filtering_conditions=MetadataFilteringCondition(
logical_operator="and",
conditions=[
Condition(name="document_name", comparison_operator="is", value="{{#start.q#}}"),
],
),
)
node_id = str(uuid.uuid4())
config = {"id": node_id, "data": node_data.model_dump()}
node = KnowledgeRetrievalNode(
id=node_id,
config=config,
graph_init_params=mock_graph_init_params,
graph_runtime_state=mock_graph_runtime_state,
rag_retrieval=mock_rag_retrieval,
)
mock_rag_retrieval.knowledge_retrieval.return_value = []
mock_rag_retrieval.llm_usage = LLMUsage.empty_usage()
# Act
node._fetch_dataset_retriever(node_data=node_data, variables=variables)
# Assert the passed request has resolved value
call_args = mock_rag_retrieval.knowledge_retrieval.call_args
request = call_args[1]["request"]
assert request.metadata_filtering_conditions is not None
assert request.metadata_filtering_conditions.conditions[0].value == "readme"

View File

@@ -16,6 +16,7 @@ from dify_graph.nodes.document_extractor.node import (
_extract_text_from_excel,
_extract_text_from_pdf,
_extract_text_from_plain_text,
_normalize_docx_zip,
)
from dify_graph.variables import ArrayFileSegment
from dify_graph.variables.segments import ArrayStringSegment
@@ -86,6 +87,38 @@ def test_run_invalid_variable_type(document_extractor_node, mock_graph_runtime_s
assert "is not an ArrayFileSegment" in result.error
def test_run_empty_file_list_returns_succeeded(document_extractor_node, mock_graph_runtime_state):
"""Empty file list should return SUCCEEDED with empty documents and ArrayStringSegment([])."""
document_extractor_node.graph_runtime_state = mock_graph_runtime_state
# Provide an actual ArrayFileSegment with an empty list
mock_graph_runtime_state.variable_pool.get.return_value = ArrayFileSegment(value=[])
result = document_extractor_node._run()
assert isinstance(result, NodeRunResult)
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED, result.error
assert result.process_data.get("documents") == []
assert result.outputs["text"] == ArrayStringSegment(value=[])
def test_run_none_only_file_list_returns_succeeded(document_extractor_node, mock_graph_runtime_state):
"""A file list containing only None (e.g., [None]) should be filtered to [] and succeed."""
document_extractor_node.graph_runtime_state = mock_graph_runtime_state
# Use a Mock to bypass type validation for None entries in the list
afs = Mock(spec=ArrayFileSegment)
afs.value = [None]
mock_graph_runtime_state.variable_pool.get.return_value = afs
result = document_extractor_node._run()
assert isinstance(result, NodeRunResult)
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED, result.error
assert result.process_data.get("documents") == []
assert result.outputs["text"] == ArrayStringSegment(value=[])
@pytest.mark.parametrize(
("mime_type", "file_content", "expected_text", "transfer_method", "extension"),
[
@@ -385,3 +418,58 @@ def test_extract_text_from_excel_numeric_type_column(mock_excel_file):
expected_manual = "| 1.0 | 1.1 |\n| --- | --- |\n| Test | Test |\n\n"
assert expected_manual == result
def _make_docx_zip(use_backslash: bool) -> bytes:
"""Helper to build a minimal in-memory DOCX zip.
When use_backslash=True the ZIP entry names use backslash separators
(as produced by Evernote on Windows), otherwise forward slashes are used.
"""
import zipfile
sep = "\\" if use_backslash else "/"
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("[Content_Types].xml", b"<Types/>")
zf.writestr(f"_rels{sep}.rels", b"<Relationships/>")
zf.writestr(f"word{sep}document.xml", b"<w:document/>")
zf.writestr(f"word{sep}_rels{sep}document.xml.rels", b"<Relationships/>")
return buf.getvalue()
def test_normalize_docx_zip_replaces_backslashes():
"""ZIP entries with backslash separators must be rewritten to forward slashes."""
import zipfile
malformed = _make_docx_zip(use_backslash=True)
fixed = _normalize_docx_zip(malformed)
with zipfile.ZipFile(io.BytesIO(fixed)) as zf:
names = zf.namelist()
assert "word/document.xml" in names
assert "word/_rels/document.xml.rels" in names
# No entry should contain a backslash after normalization
assert all("\\" not in name for name in names)
def test_normalize_docx_zip_leaves_forward_slash_unchanged():
"""ZIP entries that already use forward slashes must not be modified."""
import zipfile
normal = _make_docx_zip(use_backslash=False)
fixed = _normalize_docx_zip(normal)
with zipfile.ZipFile(io.BytesIO(fixed)) as zf:
names = zf.namelist()
assert "word/document.xml" in names
assert "word/_rels/document.xml.rels" in names
def test_normalize_docx_zip_returns_original_on_bad_zip():
"""Non-zip bytes must be returned as-is without raising."""
garbage = b"not a zip file at all"
result = _normalize_docx_zip(garbage)
assert result == garbage

View File

@@ -265,6 +265,61 @@ def test_run_exits_on_empty_batch(monkeypatch: pytest.MonkeyPatch) -> None:
cleanup.run()
def test_run_records_metrics_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
cutoff = datetime.datetime.now()
repo = FakeRepo(
batches=[[FakeRun("run-free", "t_free", cutoff)]],
delete_result={
"runs": 0,
"node_executions": 2,
"offloads": 1,
"app_logs": 3,
"trigger_logs": 4,
"pauses": 5,
"pause_reasons": 6,
},
)
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10)
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False)
batch_calls: list[dict[str, object]] = []
completion_calls: list[dict[str, object]] = []
monkeypatch.setattr(cleanup._metrics, "record_batch", lambda **kwargs: batch_calls.append(kwargs))
monkeypatch.setattr(cleanup._metrics, "record_completion", lambda **kwargs: completion_calls.append(kwargs))
cleanup.run()
assert len(batch_calls) == 1
assert batch_calls[0]["batch_rows"] == 1
assert batch_calls[0]["targeted_runs"] == 1
assert batch_calls[0]["deleted_runs"] == 1
assert batch_calls[0]["related_action"] == "deleted"
assert len(completion_calls) == 1
assert completion_calls[0]["status"] == "success"
def test_run_records_failed_metrics(monkeypatch: pytest.MonkeyPatch) -> None:
class FailingRepo(FakeRepo):
def delete_runs_with_related(
self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None
) -> dict[str, int]:
raise RuntimeError("delete failed")
cutoff = datetime.datetime.now()
repo = FailingRepo(batches=[[FakeRun("run-free", "t_free", cutoff)]])
cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10)
monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False)
completion_calls: list[dict[str, object]] = []
monkeypatch.setattr(cleanup._metrics, "record_completion", lambda **kwargs: completion_calls.append(kwargs))
with pytest.raises(RuntimeError, match="delete failed"):
cleanup.run()
assert len(completion_calls) == 1
assert completion_calls[0]["status"] == "failed"
def test_run_dry_run_skips_deletions(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
cutoff = datetime.datetime.now()
repo = FakeRepo(

View File

@@ -554,11 +554,9 @@ class TestMessagesCleanServiceFromDays:
MessagesCleanService.from_days(policy=policy, days=-1)
# Act
with patch("services.retention.conversation.messages_clean_service.datetime", autospec=True) as mock_datetime:
with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now:
fixed_now = datetime.datetime(2024, 6, 15, 14, 0, 0)
mock_datetime.datetime.now.return_value = fixed_now
mock_datetime.timedelta = datetime.timedelta
mock_now.return_value = fixed_now
service = MessagesCleanService.from_days(policy=policy, days=0)
# Assert
@@ -586,11 +584,9 @@ class TestMessagesCleanServiceFromDays:
dry_run = True
# Act
with patch("services.retention.conversation.messages_clean_service.datetime", autospec=True) as mock_datetime:
with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now:
fixed_now = datetime.datetime(2024, 6, 15, 10, 30, 0)
mock_datetime.datetime.now.return_value = fixed_now
mock_datetime.timedelta = datetime.timedelta
mock_now.return_value = fixed_now
service = MessagesCleanService.from_days(
policy=policy,
days=days,
@@ -613,11 +609,9 @@ class TestMessagesCleanServiceFromDays:
policy = BillingDisabledPolicy()
# Act
with patch("services.retention.conversation.messages_clean_service.datetime", autospec=True) as mock_datetime:
with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now:
fixed_now = datetime.datetime(2024, 6, 15, 10, 30, 0)
mock_datetime.datetime.now.return_value = fixed_now
mock_datetime.timedelta = datetime.timedelta
mock_now.return_value = fixed_now
service = MessagesCleanService.from_days(policy=policy)
# Assert
@@ -625,3 +619,53 @@ class TestMessagesCleanServiceFromDays:
assert service._end_before == expected_end_before
assert service._batch_size == 1000 # default
assert service._dry_run is False # default
class TestMessagesCleanServiceRun:
"""Unit tests for MessagesCleanService.run instrumentation behavior."""
def test_run_records_completion_metrics_on_success(self):
# Arrange
service = MessagesCleanService(
policy=BillingDisabledPolicy(),
start_from=datetime.datetime(2024, 1, 1),
end_before=datetime.datetime(2024, 1, 2),
batch_size=100,
dry_run=False,
)
expected_stats = {
"batches": 1,
"total_messages": 10,
"filtered_messages": 5,
"total_deleted": 5,
}
service._clean_messages_by_time_range = MagicMock(return_value=expected_stats) # type: ignore[method-assign]
completion_calls: list[dict[str, object]] = []
service._metrics.record_completion = lambda **kwargs: completion_calls.append(kwargs) # type: ignore[method-assign]
# Act
result = service.run()
# Assert
assert result == expected_stats
assert len(completion_calls) == 1
assert completion_calls[0]["status"] == "success"
def test_run_records_completion_metrics_on_failure(self):
# Arrange
service = MessagesCleanService(
policy=BillingDisabledPolicy(),
start_from=datetime.datetime(2024, 1, 1),
end_before=datetime.datetime(2024, 1, 2),
batch_size=100,
dry_run=False,
)
service._clean_messages_by_time_range = MagicMock(side_effect=RuntimeError("clean failed")) # type: ignore[method-assign]
completion_calls: list[dict[str, object]] = []
service._metrics.record_completion = lambda **kwargs: completion_calls.append(kwargs) # type: ignore[method-assign]
# Act & Assert
with pytest.raises(RuntimeError, match="clean failed"):
service.run()
assert len(completion_calls) == 1
assert completion_calls[0]["status"] == "failed"

View File

@@ -6,6 +6,13 @@ from typing import Any
class ConfigHelper:
_LEGACY_SECTION_MAP = {
"admin_config": "admin",
"token_config": "auth",
"app_config": "app",
"api_key_config": "api_key",
}
"""Helper class for reading and writing configuration files."""
def __init__(self, base_dir: Path | None = None):
@@ -50,14 +57,8 @@ class ConfigHelper:
Dictionary containing config data, or None if file doesn't exist
"""
# Provide backward compatibility for old config names
if filename in ["admin_config", "token_config", "app_config", "api_key_config"]:
section_map = {
"admin_config": "admin",
"token_config": "auth",
"app_config": "app",
"api_key_config": "api_key",
}
return self.get_state_section(section_map[filename])
if filename in self._LEGACY_SECTION_MAP:
return self.get_state_section(self._LEGACY_SECTION_MAP[filename])
config_path = self.get_config_path(filename)
@@ -85,14 +86,11 @@ class ConfigHelper:
True if successful, False otherwise
"""
# Provide backward compatibility for old config names
if filename in ["admin_config", "token_config", "app_config", "api_key_config"]:
section_map = {
"admin_config": "admin",
"token_config": "auth",
"app_config": "app",
"api_key_config": "api_key",
}
return self.update_state_section(section_map[filename], data)
if filename in self._LEGACY_SECTION_MAP:
return self.update_state_section(
self._LEGACY_SECTION_MAP[filename],
data,
)
self.ensure_config_dir()
config_path = self.get_config_path(filename)

View File

@@ -2,6 +2,12 @@
- Refer to the `./docs/test.md` and `./docs/lint.md` for detailed frontend workflow instructions.
## Overlay Components (Mandatory)
- `./docs/overlay-migration.md` is the source of truth for overlay-related work.
- In new or modified code, use only overlay primitives from `@/app/components/base/ui/*`.
- Do not introduce deprecated overlay imports from `@/app/components/base/*`; when touching legacy callers, prefer migrating them and keep the allowlist shrinking (never expanding).
## Automated Test Generation
- Use `./docs/test.md` as the canonical instruction set for generating frontend automated tests.

View File

@@ -0,0 +1,257 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLinkItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '../index'
describe('context-menu wrapper', () => {
describe('ContextMenuContent', () => {
it('should position content at bottom-start with default placement when props are omitted', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent positionerProps={{ 'role': 'group', 'aria-label': 'content positioner' }}>
<ContextMenuItem>Content action</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'content positioner' })
const popup = screen.getByRole('menu')
expect(positioner).toHaveAttribute('data-side', 'bottom')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(within(popup).getByRole('menuitem', { name: 'Content action' })).toBeInTheDocument()
})
it('should apply custom placement when custom positioning props are provided', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent
placement="top-end"
sideOffset={12}
alignOffset={-3}
positionerProps={{ 'role': 'group', 'aria-label': 'custom content positioner' }}
>
<ContextMenuItem>Custom content</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'custom content positioner' })
const popup = screen.getByRole('menu')
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(within(popup).getByRole('menuitem', { name: 'Custom content' })).toBeInTheDocument()
})
it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', () => {
const handlePositionerMouseEnter = vi.fn()
const handlePopupClick = vi.fn()
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent
positionerProps={{
'role': 'group',
'aria-label': 'context content positioner',
'id': 'context-content-positioner',
'onMouseEnter': handlePositionerMouseEnter,
}}
popupProps={{
role: 'menu',
id: 'context-content-popup',
onClick: handlePopupClick,
}}
>
<ContextMenuItem>Passthrough content</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'context content positioner' })
const popup = screen.getByRole('menu')
fireEvent.mouseEnter(positioner)
fireEvent.click(popup)
expect(positioner).toHaveAttribute('id', 'context-content-positioner')
expect(popup).toHaveAttribute('id', 'context-content-popup')
expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1)
expect(handlePopupClick).toHaveBeenCalledTimes(1)
})
})
describe('ContextMenuSubContent', () => {
it('should position sub-content at right-start with default placement when props are omitted', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub open>
<ContextMenuSubTrigger>More actions</ContextMenuSubTrigger>
<ContextMenuSubContent positionerProps={{ 'role': 'group', 'aria-label': 'sub positioner' }}>
<ContextMenuItem>Sub action</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'sub positioner' })
expect(positioner).toHaveAttribute('data-side', 'right')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(screen.getByRole('menuitem', { name: 'Sub action' })).toBeInTheDocument()
})
})
describe('destructive prop behavior', () => {
it.each([true, false])('should remain interactive and not leak destructive prop on item when destructive is %s', (destructive) => {
const handleClick = vi.fn()
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
destructive={destructive}
aria-label="menu action"
id={`context-item-${String(destructive)}`}
onClick={handleClick}
>
Item label
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const item = screen.getByRole('menuitem', { name: 'menu action' })
fireEvent.click(item)
expect(item).toHaveAttribute('id', `context-item-${String(destructive)}`)
expect(item).not.toHaveAttribute('destructive')
expect(handleClick).toHaveBeenCalledTimes(1)
})
it.each([true, false])('should remain interactive and not leak destructive prop on submenu trigger when destructive is %s', (destructive) => {
const handleClick = vi.fn()
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub open>
<ContextMenuSubTrigger
destructive={destructive}
aria-label="submenu action"
id={`context-sub-${String(destructive)}`}
onClick={handleClick}
>
Trigger item
</ContextMenuSubTrigger>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>,
)
const trigger = screen.getByRole('menuitem', { name: 'submenu action' })
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('id', `context-sub-${String(destructive)}`)
expect(trigger).not.toHaveAttribute('destructive')
expect(handleClick).toHaveBeenCalledTimes(1)
})
it.each([true, false])('should remain interactive and not leak destructive prop on link item when destructive is %s', (destructive) => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLinkItem
destructive={destructive}
href="https://example.com/docs"
aria-label="context docs link"
id={`context-link-${String(destructive)}`}
target="_blank"
rel="noopener noreferrer"
>
Docs
</ContextMenuLinkItem>
</ContextMenuContent>
</ContextMenu>,
)
const link = screen.getByRole('menuitem', { name: 'context docs link' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('id', `context-link-${String(destructive)}`)
expect(link).not.toHaveAttribute('destructive')
})
})
describe('ContextMenuLinkItem close behavior', () => {
it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLinkItem
href="https://example.com/docs"
closeOnClick={false}
aria-label="docs link"
>
Docs
</ContextMenuLinkItem>
</ContextMenuContent>
</ContextMenu>,
)
const link = screen.getByRole('menuitem', { name: 'docs link' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).not.toHaveAttribute('closeOnClick')
})
})
describe('ContextMenuTrigger interaction', () => {
it('should open menu when right-clicking trigger area', () => {
render(
<ContextMenu>
<ContextMenuTrigger aria-label="context trigger area">
Trigger area
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Open on right click</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const trigger = screen.getByLabelText('context trigger area')
fireEvent.contextMenu(trigger)
expect(screen.getByRole('menuitem', { name: 'Open on right click' })).toBeInTheDocument()
})
})
describe('ContextMenuSeparator', () => {
it('should render separator and keep surrounding rows when separator is between items', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>First action</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem>Second action</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
expect(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument()
expect(screen.getAllByRole('separator')).toHaveLength(1)
})
})
})

View File

@@ -0,0 +1,215 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuCheckboxItemIndicator,
ContextMenuContent,
ContextMenuGroup,
ContextMenuGroupLabel,
ContextMenuItem,
ContextMenuLinkItem,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuRadioItemIndicator,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '.'
const TriggerArea = ({ label = 'Right-click inside this area' }: { label?: string }) => (
<ContextMenuTrigger
aria-label="context menu trigger area"
render={<button type="button" className="flex h-44 w-80 select-none items-center justify-center rounded-xl border border-divider-subtle bg-background-default-subtle px-6 text-center text-sm text-text-tertiary" />}
>
{label}
</ContextMenuTrigger>
)
const meta = {
title: 'Base/Navigation/ContextMenu',
component: ContextMenu,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compound context menu built on Base UI ContextMenu. Open by right-clicking the trigger area.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof ContextMenu>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<ContextMenu>
<TriggerArea />
<ContextMenuContent>
<ContextMenuItem>Edit</ContextMenuItem>
<ContextMenuItem>Duplicate</ContextMenuItem>
<ContextMenuItem>Archive</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
),
}
export const WithSubmenu: Story = {
render: () => (
<ContextMenu>
<TriggerArea />
<ContextMenuContent>
<ContextMenuItem>Copy</ContextMenuItem>
<ContextMenuItem>Paste</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>Share</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>Email</ContextMenuItem>
<ContextMenuItem>Slack</ContextMenuItem>
<ContextMenuItem>Copy link</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>
),
}
export const WithGroupLabel: Story = {
render: () => (
<ContextMenu>
<TriggerArea />
<ContextMenuContent>
<ContextMenuGroup>
<ContextMenuGroupLabel>Actions</ContextMenuGroupLabel>
<ContextMenuItem>Rename</ContextMenuItem>
<ContextMenuItem>Duplicate</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuGroupLabel>Danger Zone</ContextMenuGroupLabel>
<ContextMenuItem destructive>Delete</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
),
}
const WithRadioItemsDemo = () => {
const [value, setValue] = useState('comfortable')
return (
<ContextMenu>
<TriggerArea label={`Right-click to set density: ${value}`} />
<ContextMenuContent>
<ContextMenuRadioGroup value={value} onValueChange={setValue}>
<ContextMenuRadioItem value="compact">
Compact
<ContextMenuRadioItemIndicator />
</ContextMenuRadioItem>
<ContextMenuRadioItem value="comfortable">
Comfortable
<ContextMenuRadioItemIndicator />
</ContextMenuRadioItem>
<ContextMenuRadioItem value="spacious">
Spacious
<ContextMenuRadioItemIndicator />
</ContextMenuRadioItem>
</ContextMenuRadioGroup>
</ContextMenuContent>
</ContextMenu>
)
}
export const WithRadioItems: Story = {
render: () => <WithRadioItemsDemo />,
}
const WithCheckboxItemsDemo = () => {
const [showToolbar, setShowToolbar] = useState(true)
const [showSidebar, setShowSidebar] = useState(false)
const [showStatusBar, setShowStatusBar] = useState(true)
return (
<ContextMenu>
<TriggerArea label="Right-click to configure panel visibility" />
<ContextMenuContent>
<ContextMenuCheckboxItem checked={showToolbar} onCheckedChange={setShowToolbar}>
Toolbar
<ContextMenuCheckboxItemIndicator />
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem checked={showSidebar} onCheckedChange={setShowSidebar}>
Sidebar
<ContextMenuCheckboxItemIndicator />
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem checked={showStatusBar} onCheckedChange={setShowStatusBar}>
Status bar
<ContextMenuCheckboxItemIndicator />
</ContextMenuCheckboxItem>
</ContextMenuContent>
</ContextMenu>
)
}
export const WithCheckboxItems: Story = {
render: () => <WithCheckboxItemsDemo />,
}
export const WithLinkItems: Story = {
render: () => (
<ContextMenu>
<TriggerArea label="Right-click to open links" />
<ContextMenuContent>
<ContextMenuLinkItem href="https://docs.dify.ai" rel="noopener noreferrer" target="_blank">
Dify Docs
</ContextMenuLinkItem>
<ContextMenuLinkItem href="https://roadmap.dify.ai" rel="noopener noreferrer" target="_blank">
Product Roadmap
</ContextMenuLinkItem>
<ContextMenuSeparator />
<ContextMenuLinkItem destructive href="https://example.com/delete" rel="noopener noreferrer" target="_blank">
Dangerous External Action
</ContextMenuLinkItem>
</ContextMenuContent>
</ContextMenu>
),
}
export const Complex: Story = {
render: () => (
<ContextMenu>
<TriggerArea label="Right-click to inspect all menu capabilities" />
<ContextMenuContent>
<ContextMenuItem>
<span aria-hidden className="i-ri-pencil-line size-4 shrink-0 text-text-tertiary" />
Rename
</ContextMenuItem>
<ContextMenuItem>
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
Duplicate
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<span aria-hidden className="i-ri-share-line size-4 shrink-0 text-text-tertiary" />
Share
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>Email</ContextMenuItem>
<ContextMenuItem>Slack</ContextMenuItem>
<ContextMenuItem>Copy Link</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem destructive>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
),
}

View File

@@ -0,0 +1,302 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu'
import * as React from 'react'
import {
menuBackdropClassName,
menuGroupLabelClassName,
menuIndicatorClassName,
menuPopupAnimationClassName,
menuPopupBaseClassName,
menuRowClassName,
menuSeparatorClassName,
} from '@/app/components/base/ui/menu-shared'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
export const ContextMenu = BaseContextMenu.Root
export const ContextMenuTrigger = BaseContextMenu.Trigger
export const ContextMenuPortal = BaseContextMenu.Portal
export const ContextMenuBackdrop = BaseContextMenu.Backdrop
export const ContextMenuSub = BaseContextMenu.SubmenuRoot
export const ContextMenuGroup = BaseContextMenu.Group
export const ContextMenuRadioGroup = BaseContextMenu.RadioGroup
type ContextMenuContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseContextMenu.Positioner>,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseContextMenu.Popup>,
'children' | 'className'
>
}
type ContextMenuPopupRenderProps = Required<Pick<ContextMenuContentProps, 'children'>> & {
placement: Placement
sideOffset: number
alignOffset: number
className?: string
popupClassName?: string
positionerProps?: ContextMenuContentProps['positionerProps']
popupProps?: ContextMenuContentProps['popupProps']
withBackdrop?: boolean
}
function renderContextMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
withBackdrop = false,
}: ContextMenuPopupRenderProps) {
const { side, align } = parsePlacement(placement)
return (
<BaseContextMenu.Portal>
{withBackdrop && (
<BaseContextMenu.Backdrop className={menuBackdropClassName} />
)}
<BaseContextMenu.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-none', className)}
{...positionerProps}
>
<BaseContextMenu.Popup
className={cn(
menuPopupBaseClassName,
menuPopupAnimationClassName,
popupClassName,
)}
{...popupProps}
>
{children}
</BaseContextMenu.Popup>
</BaseContextMenu.Positioner>
</BaseContextMenu.Portal>
)
}
export function ContextMenuContent({
children,
placement = 'bottom-start',
sideOffset = 0,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: ContextMenuContentProps) {
return renderContextMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
withBackdrop: true,
})
}
type ContextMenuItemProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.Item> & {
destructive?: boolean
}
export function ContextMenuItem({
className,
destructive,
...props
}: ContextMenuItemProps) {
return (
<BaseContextMenu.Item
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
{...props}
/>
)
}
type ContextMenuLinkItemProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.LinkItem> & {
destructive?: boolean
}
export function ContextMenuLinkItem({
className,
destructive,
closeOnClick = true,
...props
}: ContextMenuLinkItemProps) {
return (
<BaseContextMenu.LinkItem
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
closeOnClick={closeOnClick}
{...props}
/>
)
}
export function ContextMenuRadioItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItem>) {
return (
<BaseContextMenu.RadioItem
className={cn(menuRowClassName, className)}
{...props}
/>
)
}
export function ContextMenuCheckboxItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItem>) {
return (
<BaseContextMenu.CheckboxItem
className={cn(menuRowClassName, className)}
{...props}
/>
)
}
type ContextMenuIndicatorProps = Omit<React.ComponentPropsWithoutRef<'span'>, 'children'> & {
children?: React.ReactNode
}
export function ContextMenuItemIndicator({
className,
children,
...props
}: ContextMenuIndicatorProps) {
return (
<span
aria-hidden
className={cn(menuIndicatorClassName, className)}
{...props}
>
{children ?? <span aria-hidden className="i-ri-check-line h-4 w-4" />}
</span>
)
}
export function ContextMenuCheckboxItemIndicator({
className,
...props
}: Omit<React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItemIndicator>, 'children'>) {
return (
<BaseContextMenu.CheckboxItemIndicator
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
</BaseContextMenu.CheckboxItemIndicator>
)
}
export function ContextMenuRadioItemIndicator({
className,
...props
}: Omit<React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItemIndicator>, 'children'>) {
return (
<BaseContextMenu.RadioItemIndicator
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
</BaseContextMenu.RadioItemIndicator>
)
}
type ContextMenuSubTriggerProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.SubmenuTrigger> & {
destructive?: boolean
}
export function ContextMenuSubTrigger({
className,
destructive,
children,
...props
}: ContextMenuSubTriggerProps) {
return (
<BaseContextMenu.SubmenuTrigger
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
{...props}
>
{children}
<span aria-hidden className="i-ri-arrow-right-s-line ml-auto size-4 shrink-0 text-text-tertiary" />
</BaseContextMenu.SubmenuTrigger>
)
}
type ContextMenuSubContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: ContextMenuContentProps['positionerProps']
popupProps?: ContextMenuContentProps['popupProps']
}
export function ContextMenuSubContent({
children,
placement = 'right-start',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: ContextMenuSubContentProps) {
return renderContextMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
})
}
export function ContextMenuGroupLabel({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.GroupLabel>) {
return (
<BaseContextMenu.GroupLabel
className={cn(menuGroupLabelClassName, className)}
{...props}
/>
)
}
export function ContextMenuSeparator({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.Separator>) {
return (
<BaseContextMenu.Separator
className={cn(menuSeparatorClassName, className)}
{...props}
/>
)
}

View File

@@ -1,13 +1,12 @@
import { Menu } from '@base-ui/react/menu'
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import Link from 'next/link'
import { describe, expect, it, vi } from 'vitest'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuLinkItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
@@ -15,18 +14,22 @@ import {
DropdownMenuTrigger,
} from '../index'
describe('dropdown-menu wrapper', () => {
describe('alias exports', () => {
it('should map direct aliases to the corresponding Menu primitive when importing menu roots', () => {
expect(DropdownMenu).toBe(Menu.Root)
expect(DropdownMenuPortal).toBe(Menu.Portal)
expect(DropdownMenuTrigger).toBe(Menu.Trigger)
expect(DropdownMenuSub).toBe(Menu.SubmenuRoot)
expect(DropdownMenuGroup).toBe(Menu.Group)
expect(DropdownMenuRadioGroup).toBe(Menu.RadioGroup)
})
})
vi.mock('next/link', () => ({
default: ({
href,
children,
...props
}: {
href: string
children?: ReactNode
} & Omit<ComponentPropsWithoutRef<'a'>, 'href'>) => (
<a href={href} {...props}>
{children}
</a>
),
}))
describe('dropdown-menu wrapper', () => {
describe('DropdownMenuContent', () => {
it('should position content at bottom-end with default placement when props are omitted', () => {
render(
@@ -250,6 +253,99 @@ describe('dropdown-menu wrapper', () => {
})
})
describe('DropdownMenuLinkItem', () => {
it('should render as anchor and keep href/target attributes when link props are provided', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem href="https://example.com/docs" target="_blank" rel="noopener noreferrer">
Docs
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'Docs' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem
href="https://example.com/docs"
closeOnClick={false}
aria-label="docs link"
>
Docs
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'docs link' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).not.toHaveAttribute('closeOnClick')
})
it('should preserve link semantics when render prop uses a custom link component', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem
render={<Link href="/account" />}
aria-label="account link"
>
Account settings
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'account link' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', '/account')
expect(link).toHaveTextContent('Account settings')
})
it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => {
const handleClick = vi.fn()
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem
destructive={destructive}
href="https://example.com/docs"
aria-label="docs link"
id={`menu-link-${String(destructive)}`}
onClick={handleClick}
>
Docs
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'docs link' })
fireEvent.click(link)
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('id', `menu-link-${String(destructive)}`)
expect(link).not.toHaveAttribute('destructive')
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
describe('DropdownMenuSeparator', () => {
it('should forward passthrough props and handlers when separator props are provided', () => {
const handleMouseEnter = vi.fn()

View File

@@ -8,6 +8,7 @@ import {
DropdownMenuGroup,
DropdownMenuGroupLabel,
DropdownMenuItem,
DropdownMenuLinkItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
@@ -234,6 +235,22 @@ export const WithIcons: Story = {
),
}
export const WithLinkItems: Story = {
render: () => (
<DropdownMenu>
<TriggerButton label="Open links" />
<DropdownMenuContent>
<DropdownMenuLinkItem href="https://docs.dify.ai" rel="noopener noreferrer" target="_blank">
Dify Docs
</DropdownMenuLinkItem>
<DropdownMenuLinkItem href="https://roadmap.dify.ai" rel="noopener noreferrer" target="_blank">
Product Roadmap
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>
),
}
const ComplexDemo = () => {
const [sortOrder, setSortOrder] = useState('newest')
const [showArchived, setShowArchived] = useState(false)

View File

@@ -3,6 +3,14 @@
import type { Placement } from '@/app/components/base/ui/placement'
import { Menu } from '@base-ui/react/menu'
import * as React from 'react'
import {
menuGroupLabelClassName,
menuIndicatorClassName,
menuPopupAnimationClassName,
menuPopupBaseClassName,
menuRowClassName,
menuSeparatorClassName,
} from '@/app/components/base/ui/menu-shared'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
@@ -13,20 +21,13 @@ export const DropdownMenuSub = Menu.SubmenuRoot
export const DropdownMenuGroup = Menu.Group
export const DropdownMenuRadioGroup = Menu.RadioGroup
const menuRowBaseClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-none'
const menuRowStateClassName = 'data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30'
export function DropdownMenuRadioItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.RadioItem>) {
return (
<Menu.RadioItem
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
className,
)}
className={cn(menuRowClassName, className)}
{...props}
/>
)
@@ -38,10 +39,7 @@ export function DropdownMenuRadioItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.RadioItemIndicator>, 'children'>) {
return (
<Menu.RadioItemIndicator
className={cn(
'ml-auto flex shrink-0 items-center text-text-accent',
className,
)}
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
@@ -55,11 +53,7 @@ export function DropdownMenuCheckboxItem({
}: React.ComponentPropsWithoutRef<typeof Menu.CheckboxItem>) {
return (
<Menu.CheckboxItem
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
className,
)}
className={cn(menuRowClassName, className)}
{...props}
/>
)
@@ -71,10 +65,7 @@ export function DropdownMenuCheckboxItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.CheckboxItemIndicator>, 'children'>) {
return (
<Menu.CheckboxItemIndicator
className={cn(
'ml-auto flex shrink-0 items-center text-text-accent',
className,
)}
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
@@ -88,10 +79,7 @@ export function DropdownMenuGroupLabel({
}: React.ComponentPropsWithoutRef<typeof Menu.GroupLabel>) {
return (
<Menu.GroupLabel
className={cn(
'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase',
className,
)}
className={cn(menuGroupLabelClassName, className)}
{...props}
/>
)
@@ -148,8 +136,8 @@ function renderDropdownMenuPopup({
>
<Menu.Popup
className={cn(
'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg backdrop-blur-[5px]',
'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
menuPopupBaseClassName,
menuPopupAnimationClassName,
popupClassName,
)}
{...popupProps}
@@ -195,12 +183,7 @@ export function DropdownMenuSubTrigger({
}: DropdownMenuSubTriggerProps) {
return (
<Menu.SubmenuTrigger
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
destructive && 'text-text-destructive',
className,
)}
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
{...props}
>
{children}
@@ -253,12 +236,26 @@ export function DropdownMenuItem({
}: DropdownMenuItemProps) {
return (
<Menu.Item
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
destructive && 'text-text-destructive',
className,
)}
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
{...props}
/>
)
}
type DropdownMenuLinkItemProps = React.ComponentPropsWithoutRef<typeof Menu.LinkItem> & {
destructive?: boolean
}
export function DropdownMenuLinkItem({
className,
destructive,
closeOnClick = true,
...props
}: DropdownMenuLinkItemProps) {
return (
<Menu.LinkItem
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
closeOnClick={closeOnClick}
{...props}
/>
)
@@ -270,7 +267,7 @@ export function DropdownMenuSeparator({
}: React.ComponentPropsWithoutRef<typeof Menu.Separator>) {
return (
<Menu.Separator
className={cn('my-1 h-px bg-divider-subtle', className)}
className={cn(menuSeparatorClassName, className)}
{...props}
/>
)

View File

@@ -0,0 +1,7 @@
export const menuRowClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-none data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30'
export const menuIndicatorClassName = 'ml-auto flex shrink-0 items-center text-text-accent'
export const menuGroupLabelClassName = 'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase'
export const menuSeparatorClassName = 'my-1 h-px bg-divider-subtle'
export const menuPopupBaseClassName = 'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-none focus:outline-none focus-visible:outline-none backdrop-blur-[5px]'
export const menuPopupAnimationClassName = 'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none'
export const menuBackdropClassName = 'fixed inset-0 z-50 bg-transparent transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none'

View File

@@ -184,7 +184,7 @@ export default function Compliance() {
<DropdownMenuSubContent
popupClassName="w-[337px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
>
<DropdownMenuGroup className="p-1">
<DropdownMenuGroup className="py-1">
<ComplianceDocRowItem
icon={<Soc2 aria-hidden className="size-7 shrink-0" />}
label={t('compliance.soc2Type1', { ns: 'common' })}

View File

@@ -9,7 +9,7 @@ import { resetUser } from '@/app/components/base/amplitude/utils'
import Avatar from '@/app/components/base/avatar'
import PremiumBadge from '@/app/components/base/premium-badge'
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
@@ -41,12 +41,12 @@ function AccountMenuRouteItem({
trailing,
}: AccountMenuRouteItemProps) {
return (
<DropdownMenuItem
<DropdownMenuLinkItem
className="justify-between"
render={<Link href={href} />}
>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
</DropdownMenuLinkItem>
)
}
@@ -64,12 +64,14 @@ function AccountMenuExternalItem({
trailing,
}: AccountMenuExternalItemProps) {
return (
<DropdownMenuItem
<DropdownMenuLinkItem
className="justify-between"
render={<a href={href} rel="noopener noreferrer" target="_blank" />}
href={href}
rel="noopener noreferrer"
target="_blank"
>
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
</DropdownMenuLinkItem>
)
}
@@ -101,7 +103,7 @@ type AccountMenuSectionProps = {
}
function AccountMenuSection({ children }: AccountMenuSectionProps) {
return <DropdownMenuGroup className="p-1">{children}</DropdownMenuGroup>
return <DropdownMenuGroup className="py-1">{children}</DropdownMenuGroup>
}
export default function AppSelector() {
@@ -144,8 +146,8 @@ export default function AppSelector() {
sideOffset={6}
popupClassName="w-60 max-w-80 !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
>
<DropdownMenuGroup className="px-1 py-1">
<div className="flex flex-nowrap items-center py-2 pl-3 pr-2">
<DropdownMenuGroup className="py-1">
<div className="mx-1 flex flex-nowrap items-center py-2 pl-3 pr-2">
<div className="grow">
<div className="break-all text-text-primary system-md-medium">
{userProfile.name}

View File

@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils'
import { Plan } from '@/app/components/billing/type'
import { SUPPORT_EMAIL_ADDRESS, ZENDESK_WIDGET_KEY } from '@/config'
@@ -31,7 +31,7 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
<DropdownMenuSubContent
popupClassName="w-[216px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
>
<DropdownMenuGroup className="p-1">
<DropdownMenuGroup className="py-1">
{hasDedicatedChannel && hasZendeskWidget && (
<DropdownMenuItem
className="justify-between"
@@ -47,37 +47,43 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
</DropdownMenuItem>
)}
{hasDedicatedChannel && !hasZendeskWidget && (
<DropdownMenuItem
<DropdownMenuLinkItem
className="justify-between"
render={<a href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version, SUPPORT_EMAIL_ADDRESS)} rel="noopener noreferrer" target="_blank" />}
href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version, SUPPORT_EMAIL_ADDRESS)}
rel="noopener noreferrer"
target="_blank"
>
<MenuItemContent
iconClassName="i-ri-mail-send-line"
label={t('userProfile.emailSupport', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuItem>
</DropdownMenuLinkItem>
)}
<DropdownMenuItem
<DropdownMenuLinkItem
className="justify-between"
render={<a href="https://forum.dify.ai/" rel="noopener noreferrer" target="_blank" />}
href="https://forum.dify.ai/"
rel="noopener noreferrer"
target="_blank"
>
<MenuItemContent
iconClassName="i-ri-discuss-line"
label={t('userProfile.forum', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuItem>
<DropdownMenuItem
</DropdownMenuLinkItem>
<DropdownMenuLinkItem
className="justify-between"
render={<a href="https://discord.gg/5AEfbxcd9k" rel="noopener noreferrer" target="_blank" />}
href="https://discord.gg/5AEfbxcd9k"
rel="noopener noreferrer"
target="_blank"
>
<MenuItemContent
iconClassName="i-ri-discord-line"
label={t('userProfile.community', { ns: 'common' })}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuItem>
</DropdownMenuLinkItem>
</DropdownMenuGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>

View File

@@ -111,11 +111,11 @@ const ToolItem: FC<Props> = ({
})
}}
>
<div className={cn('system-sm-medium h-8 truncate border-l-2 border-divider-subtle pl-4 leading-8 text-text-secondary')}>
<div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 text-text-secondary system-sm-medium')}>
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
</div>
{isAdded && (
<div className="system-xs-regular mr-4 text-text-tertiary">{t('addToolModal.added', { ns: 'tools' })}</div>
<div className="mr-4 text-text-tertiary system-xs-regular">{t('addToolModal.added', { ns: 'tools' })}</div>
)}
</div>
</Tooltip>

View File

@@ -77,11 +77,11 @@ const TriggerPluginActionItem: FC<Props> = ({
})
}}
>
<div className={cn('system-sm-medium h-8 truncate border-l-2 border-divider-subtle pl-4 leading-8 text-text-secondary')}>
<div className={cn('truncate border-l-2 border-divider-subtle py-2 pl-4 text-text-secondary system-sm-medium')}>
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
</div>
{isAdded && (
<div className="system-xs-regular mr-4 text-text-tertiary">{t('addToolModal.added', { ns: 'tools' })}</div>
<div className="mr-4 text-text-tertiary system-xs-regular">{t('addToolModal.added', { ns: 'tools' })}</div>
)}
</div>
</Tooltip>

View File

@@ -1422,21 +1422,136 @@ export const useNodesInteractions = () => {
extent: currentNode.extent,
zIndex: currentNode.zIndex,
})
const nodesConnectedSourceOrTargetHandleIdsMap
= getNodesConnectedSourceOrTargetHandleIdsMap(
connectedEdges.map(edge => ({ type: 'remove', edge })),
nodes,
)
const newNodes = produce(nodes, (draft) => {
const parentNode = nodes.find(node => node.id === currentNode.parentId)
const newNodeIsInIteration
= !!parentNode && parentNode.data.type === BlockEnum.Iteration
const newNodeIsInLoop
= !!parentNode && parentNode.data.type === BlockEnum.Loop
const outgoingEdges = connectedEdges.filter(
edge => edge.source === currentNodeId,
)
const normalizedSourceHandle = sourceHandle || 'source'
const outgoingHandles = new Set(
outgoingEdges.map(edge => edge.sourceHandle || 'source'),
)
const branchSourceHandle = currentNode.data._targetBranches?.[0]?.id
let outgoingHandleToPreserve = normalizedSourceHandle
if (!outgoingHandles.has(outgoingHandleToPreserve)) {
if (branchSourceHandle && outgoingHandles.has(branchSourceHandle))
outgoingHandleToPreserve = branchSourceHandle
else if (outgoingHandles.has('source'))
outgoingHandleToPreserve = 'source'
else
outgoingHandleToPreserve = outgoingEdges[0]?.sourceHandle || 'source'
}
const outgoingEdgesToPreserve = outgoingEdges.filter(
edge => (edge.sourceHandle || 'source') === outgoingHandleToPreserve,
)
const outgoingEdgeIds = new Set(
outgoingEdgesToPreserve.map(edge => edge.id),
)
const newNodeSourceHandle = newCurrentNode.data._targetBranches?.[0]?.id || 'source'
const reconnectedEdges = connectedEdges.reduce<Edge[]>(
(acc, edge) => {
if (outgoingEdgeIds.has(edge.id)) {
const originalTargetNode = nodes.find(
node => node.id === edge.target,
)
const targetNodeForEdge
= originalTargetNode && originalTargetNode.id !== currentNodeId
? originalTargetNode
: newCurrentNode
if (!targetNodeForEdge)
return acc
const targetHandle = edge.targetHandle || 'target'
const targetParentNode
= targetNodeForEdge.id === newCurrentNode.id
? parentNode || null
: nodes.find(node => node.id === targetNodeForEdge.parentId)
|| null
const isInIteration
= !!targetParentNode
&& targetParentNode.data.type === BlockEnum.Iteration
const isInLoop
= !!targetParentNode
&& targetParentNode.data.type === BlockEnum.Loop
acc.push({
...edge,
id: `${newCurrentNode.id}-${newNodeSourceHandle}-${targetNodeForEdge.id}-${targetHandle}`,
source: newCurrentNode.id,
sourceHandle: newNodeSourceHandle,
target: targetNodeForEdge.id,
targetHandle,
type: CUSTOM_EDGE,
data: {
...(edge.data || {}),
sourceType: newCurrentNode.data.type,
targetType: targetNodeForEdge.data.type,
isInIteration,
iteration_id: isInIteration
? targetNodeForEdge.parentId
: undefined,
isInLoop,
loop_id: isInLoop ? targetNodeForEdge.parentId : undefined,
_connectedNodeIsSelected: false,
},
zIndex: targetNodeForEdge.parentId
? isInIteration
? ITERATION_CHILDREN_Z_INDEX
: LOOP_CHILDREN_Z_INDEX
: 0,
})
}
if (
edge.target === currentNodeId
&& edge.source !== currentNodeId
&& !outgoingEdgeIds.has(edge.id)
) {
const sourceNode = nodes.find(node => node.id === edge.source)
if (!sourceNode)
return acc
const targetHandle = edge.targetHandle || 'target'
const sourceHandle = edge.sourceHandle || 'source'
acc.push({
...edge,
id: `${sourceNode.id}-${sourceHandle}-${newCurrentNode.id}-${targetHandle}`,
source: sourceNode.id,
sourceHandle,
target: newCurrentNode.id,
targetHandle,
type: CUSTOM_EDGE,
data: {
...(edge.data || {}),
sourceType: sourceNode.data.type,
targetType: newCurrentNode.data.type,
isInIteration: newNodeIsInIteration,
iteration_id: newNodeIsInIteration
? newCurrentNode.parentId
: undefined,
isInLoop: newNodeIsInLoop,
loop_id: newNodeIsInLoop ? newCurrentNode.parentId : undefined,
_connectedNodeIsSelected: false,
},
zIndex: newCurrentNode.parentId
? newNodeIsInIteration
? ITERATION_CHILDREN_Z_INDEX
: LOOP_CHILDREN_Z_INDEX
: 0,
})
}
return acc
},
[],
)
const nodesWithNewNode = produce(nodes, (draft) => {
draft.forEach((node) => {
node.data.selected = false
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
node.data = {
...node.data,
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
}
}
})
const index = draft.findIndex(node => node.id === currentNodeId)
@@ -1446,18 +1561,32 @@ export const useNodesInteractions = () => {
if (newLoopStartNode)
draft.push(newLoopStartNode)
})
setNodes(newNodes)
const newEdges = produce(edges, (draft) => {
const filtered = draft.filter(
edge =>
!connectedEdges.find(
connectedEdge => connectedEdge.id === edge.id,
),
const nodesConnectedSourceOrTargetHandleIdsMap
= getNodesConnectedSourceOrTargetHandleIdsMap(
[
...connectedEdges.map(edge => ({ type: 'remove', edge })),
...reconnectedEdges.map(edge => ({ type: 'add', edge })),
],
nodesWithNewNode,
)
return filtered
const newNodes = produce(nodesWithNewNode, (draft) => {
draft.forEach((node) => {
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
node.data = {
...node.data,
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
}
}
})
})
setEdges(newEdges)
setNodes(newNodes)
const remainingEdges = edges.filter(
edge =>
!connectedEdges.find(
connectedEdge => connectedEdge.id === edge.id,
),
)
setEdges([...remainingEdges, ...reconnectedEdges])
if (nodeType === BlockEnum.TriggerWebhook) {
handleSyncWorkflowDraft(true, true, {
onSuccess: () => autoGenerateWebhookUrl(newCurrentNode.id),
@@ -1606,6 +1735,7 @@ export const useNodesInteractions = () => {
const offsetX = currentPosition.x - x
const offsetY = currentPosition.y - y
let idMapping: Record<string, string> = {}
const pastedNodesMap: Record<string, Node> = {}
const parentChildrenToAppend: { parentId: string, childId: string, childType: BlockEnum }[] = []
clipboardElements.forEach((nodeToPaste, index) => {
const nodeType = nodeToPaste.data.type
@@ -1665,7 +1795,21 @@ export const useNodesInteractions = () => {
newLoopStartNode!.parentId = newNode.id;
(newNode.data as LoopNodeType).start_node_id = newLoopStartNode!.id
newChildren = handleNodeLoopChildrenCopy(nodeToPaste.id, newNode.id)
const oldLoopStartNode = nodes.find(
n =>
n.parentId === nodeToPaste.id
&& n.type === CUSTOM_LOOP_START_NODE,
)
idMapping[oldLoopStartNode!.id] = newLoopStartNode!.id
const { copyChildren, newIdMapping }
= handleNodeLoopChildrenCopy(
nodeToPaste.id,
newNode.id,
idMapping,
)
newChildren = copyChildren
idMapping = newIdMapping
newChildren.forEach((child) => {
newNode.data._children?.push({
nodeId: child.id,
@@ -1710,18 +1854,31 @@ export const useNodesInteractions = () => {
}
}
idMapping[nodeToPaste.id] = newNode.id
nodesToPaste.push(newNode)
pastedNodesMap[newNode.id] = newNode
if (newChildren.length)
if (newChildren.length) {
newChildren.forEach((child) => {
pastedNodesMap[child.id] = child
})
nodesToPaste.push(...newChildren)
}
})
// only handle edge when paste nested block
// Rebuild edges where both endpoints are part of the pasted set.
edges.forEach((edge) => {
const sourceId = idMapping[edge.source]
const targetId = idMapping[edge.target]
if (sourceId && targetId) {
const sourceNode = pastedNodesMap[sourceId]
const targetNode = pastedNodesMap[targetId]
const parentNode = sourceNode?.parentId && sourceNode.parentId === targetNode?.parentId
? pastedNodesMap[sourceNode.parentId] ?? nodes.find(n => n.id === sourceNode.parentId)
: null
const isInIteration = parentNode?.data.type === BlockEnum.Iteration
const isInLoop = parentNode?.data.type === BlockEnum.Loop
const newEdge: Edge = {
...edge,
id: `${sourceId}-${edge.sourceHandle}-${targetId}-${edge.targetHandle}`,
@@ -1729,8 +1886,19 @@ export const useNodesInteractions = () => {
target: targetId,
data: {
...edge.data,
isInIteration,
iteration_id: isInIteration ? parentNode?.id : undefined,
isInLoop,
loop_id: isInLoop ? parentNode?.id : undefined,
_connectedNodeIsSelected: false,
},
zIndex: parentNode
? isInIteration
? ITERATION_CHILDREN_Z_INDEX
: isInLoop
? LOOP_CHILDREN_Z_INDEX
: 0
: 0,
}
edgesToPaste.push(newEdge)
}

View File

@@ -108,12 +108,13 @@ export const useNodeLoopInteractions = () => {
handleNodeLoopRerender(parentId)
}, [store, handleNodeLoopRerender])
const handleNodeLoopChildrenCopy = useCallback((nodeId: string, newNodeId: string) => {
const handleNodeLoopChildrenCopy = useCallback((nodeId: string, newNodeId: string, idMapping: Record<string, string>) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_LOOP_START_NODE)
const newIdMapping = { ...idMapping }
return childrenNodes.map((child, index) => {
const copyChildren = childrenNodes.map((child, index) => {
const childNodeType = child.data.type as BlockEnum
const { defaultValue } = nodesMetaDataMap![childNodeType]
const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
@@ -139,8 +140,14 @@ export const useNodeLoopInteractions = () => {
zIndex: LOOP_CHILDREN_Z_INDEX,
})
newNode.id = `${newNodeId}${newNode.id + index}`
newIdMapping[child.id] = newNode.id
return newNode
})
return {
copyChildren,
newIdMapping,
}
}, [store, nodesMetaDataMap])
return {

View File

@@ -16,6 +16,7 @@ This document tracks the migration away from legacy overlay APIs.
- Replacement primitives:
- `@/app/components/base/ui/tooltip`
- `@/app/components/base/ui/dropdown-menu`
- `@/app/components/base/ui/context-menu`
- `@/app/components/base/ui/popover`
- `@/app/components/base/ui/dialog`
- `@/app/components/base/ui/alert-dialog`

View File

@@ -6554,9 +6554,6 @@
"app/components/workflow/block-selector/tool/action-item.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx": {
@@ -6576,9 +6573,6 @@
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}

View File

@@ -69,13 +69,13 @@
"@formatjs/intl-localematcher": "0.5.10",
"@headlessui/react": "2.2.1",
"@heroicons/react": "2.2.0",
"@lexical/code": "0.38.2",
"@lexical/link": "0.38.2",
"@lexical/list": "0.38.2",
"@lexical/react": "0.38.2",
"@lexical/selection": "0.38.2",
"@lexical/text": "0.38.2",
"@lexical/utils": "0.39.0",
"@lexical/code": "0.41.0",
"@lexical/link": "0.41.0",
"@lexical/list": "0.41.0",
"@lexical/react": "0.41.0",
"@lexical/selection": "0.41.0",
"@lexical/text": "0.41.0",
"@lexical/utils": "0.41.0",
"@monaco-editor/react": "4.7.0",
"@octokit/core": "6.1.6",
"@octokit/request-error": "6.1.8",
@@ -122,7 +122,7 @@
"katex": "0.16.25",
"ky": "1.12.0",
"lamejs": "1.2.1",
"lexical": "0.38.2",
"lexical": "0.41.0",
"mermaid": "11.11.0",
"mime": "4.1.0",
"mitt": "3.0.1",
@@ -216,7 +216,7 @@
"@vitejs/plugin-react": "5.1.4",
"@vitejs/plugin-rsc": "0.5.21",
"@vitest/coverage-v8": "4.0.18",
"agentation": "2.2.1",
"agentation": "2.3.0",
"autoprefixer": "10.4.21",
"code-inspector-plugin": "1.4.2",
"cross-env": "10.1.0",
@@ -243,7 +243,7 @@
"tsx": "4.21.0",
"typescript": "5.9.3",
"uglify-js": "3.19.3",
"vinext": "https://pkg.pr.new/hyoban/vinext@556a6d6",
"vinext": "https://pkg.pr.new/vinext@1a2fd61",
"vite": "8.0.0-beta.16",
"vite-plugin-inspect": "11.3.3",
"vite-tsconfig-paths": "6.1.1",
@@ -252,6 +252,7 @@
},
"pnpm": {
"overrides": {
"@lexical/code": "npm:lexical-code-no-prism@0.41.0",
"@monaco-editor/loader": "1.5.0",
"@nolyfill/safe-buffer": "npm:safe-buffer@^5.2.1",
"@stylistic/eslint-plugin": "https://pkg.pr.new/@stylistic/eslint-plugin@258f9d8",

435
web/pnpm-lock.yaml generated
View File

@@ -5,6 +5,7 @@ settings:
excludeLinksFromLockfile: false
overrides:
'@lexical/code': npm:lexical-code-no-prism@0.41.0
'@monaco-editor/loader': 1.5.0
'@nolyfill/safe-buffer': npm:safe-buffer@^5.2.1
'@stylistic/eslint-plugin': https://pkg.pr.new/@stylistic/eslint-plugin@258f9d8
@@ -79,26 +80,26 @@ importers:
specifier: 2.2.0
version: 2.2.0(react@19.2.4)
'@lexical/code':
specifier: 0.38.2
version: 0.38.2
specifier: npm:lexical-code-no-prism@0.41.0
version: lexical-code-no-prism@0.41.0(@lexical/utils@0.41.0)(lexical@0.41.0)
'@lexical/link':
specifier: 0.38.2
version: 0.38.2
specifier: 0.41.0
version: 0.41.0
'@lexical/list':
specifier: 0.38.2
version: 0.38.2
specifier: 0.41.0
version: 0.41.0
'@lexical/react':
specifier: 0.38.2
version: 0.38.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29)
specifier: 0.41.0
version: 0.41.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29)
'@lexical/selection':
specifier: 0.38.2
version: 0.38.2
specifier: 0.41.0
version: 0.41.0
'@lexical/text':
specifier: 0.38.2
version: 0.38.2
specifier: 0.41.0
version: 0.41.0
'@lexical/utils':
specifier: 0.39.0
version: 0.39.0
specifier: 0.41.0
version: 0.41.0
'@monaco-editor/react':
specifier: 4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -238,8 +239,8 @@ importers:
specifier: 1.2.1
version: 1.2.1
lexical:
specifier: 0.38.2
version: 0.38.2
specifier: 0.41.0
version: 0.41.0
mermaid:
specifier: 11.11.0
version: 11.11.0
@@ -515,8 +516,8 @@ importers:
specifier: 4.0.18
version: 4.0.18(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
agentation:
specifier: 2.2.1
version: 2.2.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
specifier: 2.3.0
version: 2.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
autoprefixer:
specifier: 10.4.21
version: 10.4.21(postcss@8.5.6)
@@ -596,8 +597,8 @@ importers:
specifier: 3.19.3
version: 3.19.3
vinext:
specifier: https://pkg.pr.new/hyoban/vinext@556a6d6
version: https://pkg.pr.new/hyoban/vinext@556a6d6(next@16.1.5(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
specifier: https://pkg.pr.new/vinext@1a2fd61
version: https://pkg.pr.new/vinext@1a2fd61(next@16.1.5(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
vite:
specifier: 8.0.0-beta.16
version: 8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
@@ -1682,98 +1683,74 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lexical/clipboard@0.38.2':
resolution: {integrity: sha512-dDShUplCu8/o6BB9ousr3uFZ9bltR+HtleF/Tl8FXFNPpZ4AXhbLKUoJuucRuIr+zqT7RxEv/3M6pk/HEoE6NQ==}
'@lexical/clipboard@0.41.0':
resolution: {integrity: sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA==}
'@lexical/clipboard@0.39.0':
resolution: {integrity: sha512-ylrHy8M+I5EH4utwqivslugqQhvgLTz9VEJdrb2RjbhKQEXwMcqKCRWh6cRfkYx64onE2YQE0nRIdzHhExEpLQ==}
'@lexical/code@0.38.2':
resolution: {integrity: sha512-wpqgbmPsfi/+8SYP0zI2kml09fGPRhzO5litR9DIbbSGvcbawMbRNcKLO81DaTbsJRnBJiQvbBBBJAwZKRqgBw==}
'@lexical/devtools-core@0.38.2':
resolution: {integrity: sha512-hlN0q7taHNzG47xKynQLCAFEPOL8l6IP79C2M18/FE1+htqNP35q4rWhYhsptGlKo4me4PtiME7mskvr7T4yqA==}
'@lexical/devtools-core@0.41.0':
resolution: {integrity: sha512-FzJtluBhBc8bKS11TUZe72KoZN/hnzIyiiM0SPJAsPwGpoXuM01jqpXQGybWf/1bWB+bmmhOae7O4Nywi/Csuw==}
peerDependencies:
react: '>=17.x'
react-dom: '>=17.x'
'@lexical/dragon@0.38.2':
resolution: {integrity: sha512-riOhgo+l4oN50RnLGhcqeUokVlMZRc+NDrxRNs2lyKSUdC4vAhAmAVUHDqYPyb4K4ZSw4ebZ3j8hI2zO4O3BbA==}
'@lexical/dragon@0.41.0':
resolution: {integrity: sha512-gBEqkk8Q6ZPruvDaRcOdF1EK9suCVBODzOCcR+EnoJTaTjfDkCM7pkPAm4w90Wa1wCZEtFHvCfas+jU9MDSumg==}
'@lexical/extension@0.38.2':
resolution: {integrity: sha512-qbUNxEVjAC0kxp7hEMTzktj0/51SyJoIJWK6Gm790b4yNBq82fEPkksfuLkRg9VQUteD0RT1Nkjy8pho8nNamw==}
'@lexical/extension@0.41.0':
resolution: {integrity: sha512-sF4SPiP72yXvIGchmmIZ7Yg2XZTxNLOpFEIIzdqG7X/1fa1Ham9P/T7VbrblWpF6Ei5LJtK9JgNVB0hb4l3o1g==}
'@lexical/extension@0.39.0':
resolution: {integrity: sha512-mp/WcF8E53FWPiUHgHQz382J7u7C4+cELYNkC00dKaymf8NhS6M65Y8tyDikNGNUcLXSzaluwK0HkiKjTYGhVQ==}
'@lexical/hashtag@0.41.0':
resolution: {integrity: sha512-tFWM74RW4KU0E/sj2aowfWl26vmLUTp331CgVESnhQKcZBfT40KJYd57HEqBDTfQKn4MUhylQCCA0hbpw6EeFQ==}
'@lexical/hashtag@0.38.2':
resolution: {integrity: sha512-jNI4Pv+plth39bjOeeQegMypkjDmoMWBMZtV0lCynBpkkPFlfMnyL9uzW/IxkZnX8LXWSw5mbWk07nqOUNTCrA==}
'@lexical/history@0.41.0':
resolution: {integrity: sha512-kGoVWsiOn62+RMjRolRa+NXZl8jFwxav6GNDiHH8yzivtoaH8n1SwUfLJELXCzeqzs81HySqD4q30VLJVTGoDg==}
'@lexical/history@0.38.2':
resolution: {integrity: sha512-QWPwoVDMe/oJ0+TFhy78TDi7TWU/8bcDRFUNk1nWgbq7+2m+5MMoj90LmOFwakQHnCVovgba2qj+atZrab1dsQ==}
'@lexical/html@0.41.0':
resolution: {integrity: sha512-3RyZy+H/IDKz2D66rNN/NqYx87xVFrngfEbyu1OWtbY963RUFnopiVHCQvsge/8kT04QSZ7U/DzjVFqeNS6clg==}
'@lexical/html@0.38.2':
resolution: {integrity: sha512-pC5AV+07bmHistRwgG3NJzBMlIzSdxYO6rJU4eBNzyR4becdiLsI4iuv+aY7PhfSv+SCs7QJ9oc4i5caq48Pkg==}
'@lexical/link@0.41.0':
resolution: {integrity: sha512-Rjtx5cGWAkKcnacncbVsZ1TqRnUB2Wm4eEVKpaAEG41+kHgqghzM2P+UGT15yROroxJu8KvAC9ISiYFiU4XE1w==}
'@lexical/html@0.39.0':
resolution: {integrity: sha512-7VLWP5DpzBg3kKctpNK6PbhymKAtU6NAnKieopCfCIWlMW+EqpldteiIXGqSqrMRK0JWTmF1gKgr9nnQyOOsXw==}
'@lexical/list@0.41.0':
resolution: {integrity: sha512-RXvB+xcbzVoQLGRDOBRCacztG7V+bI95tdoTwl8pz5xvgPtAaRnkZWMDP+yMNzMJZsqEChdtpxbf0NgtMkun6g==}
'@lexical/link@0.38.2':
resolution: {integrity: sha512-UOKTyYqrdCR9+7GmH6ZVqJTmqYefKGMUHMGljyGks+OjOGZAQs78S1QgcPEqltDy+SSdPSYK7wAo6gjxZfEq9g==}
'@lexical/mark@0.41.0':
resolution: {integrity: sha512-UO5WVs9uJAYIKHSlYh4Z1gHrBBchTOi21UCYBIZ7eAs4suK84hPzD+3/LAX5CB7ZltL6ke5Sly3FOwNXv/wfpA==}
'@lexical/list@0.38.2':
resolution: {integrity: sha512-OQm9TzatlMrDZGxMxbozZEHzMJhKxAbH1TOnOGyFfzpfjbnFK2y8oLeVsfQZfZRmiqQS4Qc/rpFnRP2Ax5dsbA==}
'@lexical/markdown@0.41.0':
resolution: {integrity: sha512-bzI73JMXpjGFhqUWNV6KqfjWcgAWzwFT+J3RHtbCF5rysC8HLldBYojOgAAtPfXqfxyv2mDzsY7SoJ75s9uHZA==}
'@lexical/list@0.39.0':
resolution: {integrity: sha512-mxgSxUrakTCHtC+gF30BChQBJTsCMiMgfC2H5VvhcFwXMgsKE/aK9+a+C/sSvvzCmPXqzYsuAcGkJcrY3e5xlw==}
'@lexical/offset@0.41.0':
resolution: {integrity: sha512-2RHBXZqC8gm3X9C0AyRb0M8w7zJu5dKiasrif+jSKzsxPjAUeF1m95OtIOsWs1XLNUgASOSUqGovDZxKJslZfA==}
'@lexical/mark@0.38.2':
resolution: {integrity: sha512-U+8KGwc3cP5DxSs15HfkP2YZJDs5wMbWQAwpGqep9bKphgxUgjPViKhdi+PxIt2QEzk7WcoZWUsK1d2ty/vSmg==}
'@lexical/overflow@0.41.0':
resolution: {integrity: sha512-Iy6ZiJip8X14EBYt1zKPOrXyQ4eG9JLBEoPoSVBTiSbVd+lYicdUvaOThT0k0/qeVTN9nqTaEltBjm56IrVKCQ==}
'@lexical/markdown@0.38.2':
resolution: {integrity: sha512-ykQJ9KUpCs1+Ak6ZhQMP6Slai4/CxfLEGg/rSHNVGbcd7OaH/ICtZN5jOmIe9ExfXMWy1o8PyMu+oAM3+AWFgA==}
'@lexical/plain-text@0.41.0':
resolution: {integrity: sha512-HIsGgmFUYRUNNyvckun33UQfU7LRzDlxymHUq67+Bxd5bXqdZOrStEKJXuDX+LuLh/GXZbaWNbDLqwLBObfbQg==}
'@lexical/offset@0.38.2':
resolution: {integrity: sha512-uDky2palcY+gE6WTv6q2umm2ioTUnVqcaWlEcchP6A310rI08n6rbpmkaLSIh3mT2GJQN2QcN2x0ct5BQmKIpA==}
'@lexical/overflow@0.38.2':
resolution: {integrity: sha512-f6vkTf+YZF0EuKvUK3goh4jrnF+Z0koiNMO+7rhSMLooc5IlD/4XXix4ZLiIktUWq4BhO84b82qtrO+6oPUxtw==}
'@lexical/plain-text@0.38.2':
resolution: {integrity: sha512-xRYNHJJFCbaQgr0uErW8Im2Phv1nWHIT4VSoAlBYqLuVGZBD4p61dqheBwqXWlGGJFk+MY5C5URLiMicgpol7A==}
'@lexical/react@0.38.2':
resolution: {integrity: sha512-M3z3MkWyw3Msg4Hojr5TnO4TzL71NVPVNGoavESjdgJbTdv1ezcQqjE4feq+qs7H9jytZeuK8wsEOJfSPmNd8w==}
'@lexical/react@0.41.0':
resolution: {integrity: sha512-7+GUdZUm6sofWm+zdsWAs6cFBwKNsvsHezZTrf6k8jrZxL461ZQmbz/16b4DvjCGL9r5P1fR7md9/LCmk8TiCg==}
peerDependencies:
react: '>=17.x'
react-dom: '>=17.x'
'@lexical/rich-text@0.38.2':
resolution: {integrity: sha512-eFjeOT7YnDZYpty7Zlwlct0UxUSaYu53uLYG+Prs3NoKzsfEK7e7nYsy/BbQFfk5HoM1pYuYxFR2iIX62+YHGw==}
'@lexical/rich-text@0.41.0':
resolution: {integrity: sha512-yUcr7ZaaVTZNi8bow4CK1M8jy2qyyls1Vr+5dVjwBclVShOL/F/nFyzBOSb6RtXXRbd3Ahuk9fEleppX/RNIdw==}
'@lexical/selection@0.38.2':
resolution: {integrity: sha512-eMFiWlBH6bEX9U9sMJ6PXPxVXTrihQfFeiIlWLuTpEIDF2HRz7Uo1KFRC/yN6q0DQaj7d9NZYA6Mei5DoQuz5w==}
'@lexical/selection@0.41.0':
resolution: {integrity: sha512-1s7/kNyRzcv5uaTwsUL28NpiisqTf5xZ1zNukLsCN1xY+TWbv9RE9OxIv+748wMm4pxNczQe/UbIBODkbeknLw==}
'@lexical/selection@0.39.0':
resolution: {integrity: sha512-j0cgNuTKDCdf/4MzRnAUwEqG6C/WQp18k2WKmX5KIVZJlhnGIJmlgSBrxjo8AuZ16DIHxTm2XNB4cUDCgZNuPA==}
'@lexical/table@0.41.0':
resolution: {integrity: sha512-d3SPThBAr+oZ8O74TXU0iXM3rLbrAVC7/HcOnSAq7/AhWQW8yMutT51JQGN+0fMLP9kqoWSAojNtkdvzXfU/+A==}
'@lexical/table@0.38.2':
resolution: {integrity: sha512-uu0i7yz0nbClmHOO5ZFsinRJE6vQnFz2YPblYHAlNigiBedhqMwSv5bedrzDq8nTTHwych3mC63tcyKIrM+I1g==}
'@lexical/text@0.41.0':
resolution: {integrity: sha512-gGA+Anc7ck110EXo4KVKtq6Ui3M7Vz3OpGJ4QE6zJHWW8nV5h273koUGSutAMeoZgRVb6t01Izh3ORoFt/j1CA==}
'@lexical/table@0.39.0':
resolution: {integrity: sha512-1eH11kV4bJ0fufCYl8DpE19kHwqUI8Ev5CZwivfAtC3ntwyNkeEpjCc0pqeYYIWN/4rTZ5jgB3IJV4FntyfCzw==}
'@lexical/utils@0.41.0':
resolution: {integrity: sha512-Wlsokr5NQCq83D+7kxZ9qs5yQ3dU3Qaf2M+uXxLRoPoDaXqW8xTWZq1+ZFoEzsHzx06QoPa4Vu/40BZR91uQPg==}
'@lexical/text@0.38.2':
resolution: {integrity: sha512-+juZxUugtC4T37aE3P0l4I9tsWbogDUnTI/mgYk4Ht9g+gLJnhQkzSA8chIyfTxbj5i0A8yWrUUSw+/xA7lKUQ==}
'@lexical/utils@0.38.2':
resolution: {integrity: sha512-y+3rw15r4oAWIEXicUdNjfk8018dbKl7dWHqGHVEtqzAYefnEYdfD2FJ5KOTXfeoYfxi8yOW7FvzS4NZDi8Bfw==}
'@lexical/utils@0.39.0':
resolution: {integrity: sha512-8YChidpMJpwQc4nex29FKUeuZzC++QCS/Jt46lPuy1GS/BZQoPHFKQ5hyVvM9QVhc5CEs4WGNoaCZvZIVN8bQw==}
'@lexical/yjs@0.38.2':
resolution: {integrity: sha512-fg6ZHNrVQmy1AAxaTs8HrFbeNTJCaCoEDPi6pqypHQU3QVfqr4nq0L0EcHU/TRlR1CeduEPvZZIjUUxWTZ0u8g==}
'@lexical/yjs@0.41.0':
resolution: {integrity: sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w==}
peerDependencies:
yjs: '>=13.5.22'
@@ -3705,8 +3682,8 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
agentation@2.2.1:
resolution: {integrity: sha512-yV9P1DggI7M3SRaRwLwt+xqE5lXqg5l8xtqCr8KzEkbnH8Wa6eRATU97uKnD7cC8FrsJP62Mmw0Xf5Xi5KV50Q==}
agentation@2.3.0:
resolution: {integrity: sha512-uGcDel78I5UAVSiWnsNv0pHj+ieuHyZ4GCsL6kqEralKeIW32869JlwfsKoy5S71jseyrI6O5duU+AacJs+CmQ==}
peerDependencies:
react: '>=18.0.0'
react-dom: '>=18.0.0'
@@ -5632,11 +5609,14 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lexical@0.38.2:
resolution: {integrity: sha512-JJmfsG3c4gwBHzUGffbV7ifMNkKAWMCnYE3xJl87gty7hjyV5f3xq7eqTjP5HFYvO4XpjJvvWO2/djHp5S10tw==}
lexical-code-no-prism@0.41.0:
resolution: {integrity: sha512-cFgCC/VMXjch58iod4TIhBHb1bx7Da8IdduUwltua581dhLmugcaFnUvgC0naBaPeYVuirA6cuDsyOdPgEEDLA==}
peerDependencies:
'@lexical/utils': '>=0.28.0'
lexical: '>=0.28.0'
lexical@0.39.0:
resolution: {integrity: sha512-lpLv7MEJH5QDujEDlYqettL3ATVtNYjqyimzqgrm0RvCm3AO9WXSdsgTxuN7IAZRu88xkxCDeYubeUf4mNZVdg==}
lexical@0.41.0:
resolution: {integrity: sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA==}
lib0@0.2.117:
resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==}
@@ -7524,8 +7504,8 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vinext@https://pkg.pr.new/hyoban/vinext@556a6d6:
resolution: {tarball: https://pkg.pr.new/hyoban/vinext@556a6d6}
vinext@https://pkg.pr.new/vinext@1a2fd61:
resolution: {integrity: sha512-5Q2iQExi1QQ/EpNcJ7TA6U9o4+kxJyaM/Ocobostt9IHqod6TOzhOx+ZSfmZr7eEVZq2joaIGY6Jl3dZ1dGNjg==, tarball: https://pkg.pr.new/vinext@1a2fd61}
version: 0.0.5
engines: {node: '>=22'}
hasBin: true
@@ -9119,210 +9099,157 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lexical/clipboard@0.38.2':
'@lexical/clipboard@0.41.0':
dependencies:
'@lexical/html': 0.38.2
'@lexical/list': 0.38.2
'@lexical/selection': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/html': 0.41.0
'@lexical/list': 0.41.0
'@lexical/selection': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/clipboard@0.39.0':
'@lexical/devtools-core@0.41.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@lexical/html': 0.39.0
'@lexical/list': 0.39.0
'@lexical/selection': 0.39.0
'@lexical/utils': 0.39.0
lexical: 0.39.0
'@lexical/code@0.38.2':
dependencies:
'@lexical/utils': 0.38.2
lexical: 0.38.2
prismjs: 1.30.0
'@lexical/devtools-core@0.38.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@lexical/html': 0.38.2
'@lexical/link': 0.38.2
'@lexical/mark': 0.38.2
'@lexical/table': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/html': 0.41.0
'@lexical/link': 0.41.0
'@lexical/mark': 0.41.0
'@lexical/table': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@lexical/dragon@0.38.2':
'@lexical/dragon@0.41.0':
dependencies:
'@lexical/extension': 0.38.2
lexical: 0.38.2
'@lexical/extension': 0.41.0
lexical: 0.41.0
'@lexical/extension@0.38.2':
'@lexical/extension@0.41.0':
dependencies:
'@lexical/utils': 0.38.2
'@lexical/utils': 0.41.0
'@preact/signals-core': 1.12.2
lexical: 0.38.2
lexical: 0.41.0
'@lexical/extension@0.39.0':
'@lexical/hashtag@0.41.0':
dependencies:
'@lexical/utils': 0.39.0
'@preact/signals-core': 1.12.2
lexical: 0.39.0
'@lexical/text': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/hashtag@0.38.2':
'@lexical/history@0.41.0':
dependencies:
'@lexical/text': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/extension': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/history@0.38.2':
'@lexical/html@0.41.0':
dependencies:
'@lexical/extension': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/selection': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/html@0.38.2':
'@lexical/link@0.41.0':
dependencies:
'@lexical/selection': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/extension': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/html@0.39.0':
'@lexical/list@0.41.0':
dependencies:
'@lexical/selection': 0.39.0
'@lexical/utils': 0.39.0
lexical: 0.39.0
'@lexical/extension': 0.41.0
'@lexical/selection': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/link@0.38.2':
'@lexical/mark@0.41.0':
dependencies:
'@lexical/extension': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/list@0.38.2':
'@lexical/markdown@0.41.0':
dependencies:
'@lexical/extension': 0.38.2
'@lexical/selection': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/code': lexical-code-no-prism@0.41.0(@lexical/utils@0.41.0)(lexical@0.41.0)
'@lexical/link': 0.41.0
'@lexical/list': 0.41.0
'@lexical/rich-text': 0.41.0
'@lexical/text': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/list@0.39.0':
'@lexical/offset@0.41.0':
dependencies:
'@lexical/extension': 0.39.0
'@lexical/selection': 0.39.0
'@lexical/utils': 0.39.0
lexical: 0.39.0
lexical: 0.41.0
'@lexical/mark@0.38.2':
'@lexical/overflow@0.41.0':
dependencies:
'@lexical/utils': 0.38.2
lexical: 0.38.2
lexical: 0.41.0
'@lexical/markdown@0.38.2':
'@lexical/plain-text@0.41.0':
dependencies:
'@lexical/code': 0.38.2
'@lexical/link': 0.38.2
'@lexical/list': 0.38.2
'@lexical/rich-text': 0.38.2
'@lexical/text': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/clipboard': 0.41.0
'@lexical/dragon': 0.41.0
'@lexical/selection': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/offset@0.38.2':
dependencies:
lexical: 0.38.2
'@lexical/overflow@0.38.2':
dependencies:
lexical: 0.38.2
'@lexical/plain-text@0.38.2':
dependencies:
'@lexical/clipboard': 0.38.2
'@lexical/dragon': 0.38.2
'@lexical/selection': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/react@0.38.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29)':
'@lexical/react@0.41.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yjs@13.6.29)':
dependencies:
'@floating-ui/react': 0.27.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@lexical/devtools-core': 0.38.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@lexical/dragon': 0.38.2
'@lexical/extension': 0.38.2
'@lexical/hashtag': 0.38.2
'@lexical/history': 0.38.2
'@lexical/link': 0.38.2
'@lexical/list': 0.38.2
'@lexical/mark': 0.38.2
'@lexical/markdown': 0.38.2
'@lexical/overflow': 0.38.2
'@lexical/plain-text': 0.38.2
'@lexical/rich-text': 0.38.2
'@lexical/table': 0.38.2
'@lexical/text': 0.38.2
'@lexical/utils': 0.38.2
'@lexical/yjs': 0.38.2(yjs@13.6.29)
lexical: 0.38.2
'@lexical/devtools-core': 0.41.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@lexical/dragon': 0.41.0
'@lexical/extension': 0.41.0
'@lexical/hashtag': 0.41.0
'@lexical/history': 0.41.0
'@lexical/link': 0.41.0
'@lexical/list': 0.41.0
'@lexical/mark': 0.41.0
'@lexical/markdown': 0.41.0
'@lexical/overflow': 0.41.0
'@lexical/plain-text': 0.41.0
'@lexical/rich-text': 0.41.0
'@lexical/table': 0.41.0
'@lexical/text': 0.41.0
'@lexical/utils': 0.41.0
'@lexical/yjs': 0.41.0(yjs@13.6.29)
lexical: 0.41.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-error-boundary: 6.1.0(react@19.2.4)
transitivePeerDependencies:
- yjs
'@lexical/rich-text@0.38.2':
'@lexical/rich-text@0.41.0':
dependencies:
'@lexical/clipboard': 0.38.2
'@lexical/dragon': 0.38.2
'@lexical/selection': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
'@lexical/clipboard': 0.41.0
'@lexical/dragon': 0.41.0
'@lexical/selection': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/selection@0.38.2':
'@lexical/selection@0.41.0':
dependencies:
lexical: 0.38.2
lexical: 0.41.0
'@lexical/selection@0.39.0':
'@lexical/table@0.41.0':
dependencies:
lexical: 0.39.0
'@lexical/clipboard': 0.41.0
'@lexical/extension': 0.41.0
'@lexical/utils': 0.41.0
lexical: 0.41.0
'@lexical/table@0.38.2':
'@lexical/text@0.41.0':
dependencies:
'@lexical/clipboard': 0.38.2
'@lexical/extension': 0.38.2
'@lexical/utils': 0.38.2
lexical: 0.38.2
lexical: 0.41.0
'@lexical/table@0.39.0':
'@lexical/utils@0.41.0':
dependencies:
'@lexical/clipboard': 0.39.0
'@lexical/extension': 0.39.0
'@lexical/utils': 0.39.0
lexical: 0.39.0
'@lexical/selection': 0.41.0
lexical: 0.41.0
'@lexical/text@0.38.2':
'@lexical/yjs@0.41.0(yjs@13.6.29)':
dependencies:
lexical: 0.38.2
'@lexical/utils@0.38.2':
dependencies:
'@lexical/list': 0.38.2
'@lexical/selection': 0.38.2
'@lexical/table': 0.38.2
lexical: 0.38.2
'@lexical/utils@0.39.0':
dependencies:
'@lexical/list': 0.39.0
'@lexical/selection': 0.39.0
'@lexical/table': 0.39.0
lexical: 0.39.0
'@lexical/yjs@0.38.2(yjs@13.6.29)':
dependencies:
'@lexical/offset': 0.38.2
'@lexical/selection': 0.38.2
lexical: 0.38.2
'@lexical/offset': 0.41.0
'@lexical/selection': 0.41.0
lexical: 0.41.0
yjs: 13.6.29
'@mdx-js/loader@3.1.1(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))':
@@ -11372,7 +11299,7 @@ snapshots:
agent-base@7.1.4: {}
agentation@2.2.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
agentation@2.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
optionalDependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
@@ -13529,9 +13456,12 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lexical@0.38.2: {}
lexical-code-no-prism@0.41.0(@lexical/utils@0.41.0)(lexical@0.41.0):
dependencies:
'@lexical/utils': 0.41.0
lexical: 0.41.0
lexical@0.39.0: {}
lexical@0.41.0: {}
lib0@0.2.117:
dependencies:
@@ -15884,10 +15814,11 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vinext@https://pkg.pr.new/hyoban/vinext@556a6d6(next@16.1.5(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)):
vinext@https://pkg.pr.new/vinext@1a2fd61(next@16.1.5(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)):
dependencies:
'@unpic/react': 1.0.2(next@16.1.5(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@vercel/og': 0.8.6
'@vitejs/plugin-react': 5.1.4(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitejs/plugin-rsc': 0.5.21(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.4)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
magic-string: 0.30.21
react: 19.2.4

View File

@@ -46,7 +46,6 @@ export default defineConfig(({ mode }) => {
injectTarget: browserInitializerInjectTarget,
projectRoot,
}),
react(),
vinext(),
customI18nHmrPlugin({ injectTarget: browserInitializerInjectTarget }),
// reactGrabOpenFilePlugin({
@@ -65,13 +64,6 @@ export default defineConfig(({ mode }) => {
? {
optimizeDeps: {
exclude: ['nuqs'],
// Make Prism in lexical works
// https://github.com/vitejs/rolldown-vite/issues/396
rolldownOptions: {
output: {
strictExecutionOrder: true,
},
},
},
server: {
port: 3000,
@@ -80,15 +72,6 @@ export default defineConfig(({ mode }) => {
// SyntaxError: Named export not found. The requested module is a CommonJS module, which may not support all module.exports as named exports
noExternal: ['emoji-mart'],
},
// Make Prism in lexical works
// https://github.com/vitejs/rolldown-vite/issues/396
build: {
rolldownOptions: {
output: {
strictExecutionOrder: true,
},
},
},
}
: {}),

View File

@@ -80,6 +80,16 @@ if (typeof globalThis.IntersectionObserver === 'undefined') {
if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView)
Element.prototype.scrollIntoView = function () { /* noop */ }
// Mock DOMRect.fromRect for tests (not available in jsdom)
if (typeof DOMRect !== 'undefined' && typeof (DOMRect as typeof DOMRect & { fromRect?: unknown }).fromRect !== 'function') {
(DOMRect as typeof DOMRect & { fromRect: (rect?: DOMRectInit) => DOMRect }).fromRect = (rect = {}) => new DOMRect(
rect.x ?? 0,
rect.y ?? 0,
rect.width ?? 0,
rect.height ?? 0,
)
}
afterEach(async () => {
// Wrap cleanup in act() to flush pending React scheduler work
// This prevents "window is not defined" errors from React 19's scheduler