Compare commits

..

6 Commits

30 changed files with 1951 additions and 31 deletions

View File

@ -53,6 +53,8 @@ jobs:
- name: Run Type Checks
if: steps.changed-files.outputs.any_changed == 'true'
env:
PYREFLY_OUTPUT_FORMAT: github
run: make type-check-core
- name: Dotenv check

View File

@ -2,9 +2,10 @@
from __future__ import annotations
import argparse
import sys
_DIAGNOSTIC_PREFIXES = ("ERROR ", "WARNING ")
_DIAGNOSTIC_PREFIXES = ("ERROR ", "WARN ", "WARNING ")
_LOCATION_PREFIX = "-->"
@ -13,7 +14,7 @@ def extract_diagnostics(raw_output: str) -> str:
The full pyrefly output includes code excerpts and carets, which create noisy
diffs. This helper keeps only:
- diagnostic headline lines (``ERROR ...`` / ``WARNING ...``)
- diagnostic headline lines (``ERROR ...`` / ``WARN ...`` / ``WARNING ...``)
- the following location line (``--> path:line:column``), when present
"""
@ -36,11 +37,28 @@ def extract_diagnostics(raw_output: str) -> str:
return "\n".join(diagnostics) + "\n"
def render_diagnostics(raw_output: str, exit_code: int) -> str:
"""Render concise diagnostics and fall back to raw output on unmatched failures."""
diagnostics = extract_diagnostics(raw_output)
if diagnostics:
return diagnostics
if exit_code != 0:
return raw_output
return ""
def main() -> int:
"""Read pyrefly output from stdin and print normalized diagnostics."""
parser = argparse.ArgumentParser()
parser.add_argument("--status", type=int, default=0)
args = parser.parse_args()
raw_output = sys.stdin.read()
sys.stdout.write(extract_diagnostics(raw_output))
sys.stdout.write(render_diagnostics(raw_output, exit_code=args.status))
return 0

View File

@ -1,4 +1,4 @@
from libs.pyrefly_diagnostics import extract_diagnostics
from libs.pyrefly_diagnostics import extract_diagnostics, render_diagnostics
def test_extract_diagnostics_keeps_only_summary_and_location_lines() -> None:
@ -40,6 +40,37 @@ def test_extract_diagnostics_handles_error_without_location_line() -> None:
assert diagnostics == "ERROR unexpected pyrefly output format [bad-format]\n"
def test_extract_diagnostics_keeps_warn_headlines_and_location_lines() -> None:
# Arrange
raw_output = """INFO Checking project configured at `/tmp/project/pyrefly.toml`
WARN Skipping include pattern `/tmp/project/tests` because it is matched by `project-excludes`.
--> tests/test_containers_integration_tests/pyrefly.toml:3:1
"""
# Act
diagnostics = extract_diagnostics(raw_output)
# Assert
assert diagnostics == (
"WARN Skipping include pattern `/tmp/project/tests` because it is matched by `project-excludes`.\n"
" --> tests/test_containers_integration_tests/pyrefly.toml:3:1\n"
)
def test_render_diagnostics_falls_back_to_raw_output_for_nonzero_exit_without_matches() -> None:
# Arrange
raw_output = (
"INFO Checking project configured at `/tmp/project/pyrefly.toml`\n"
"No Python files matched pattern `/tmp/project/tests/test_containers_integration_tests`\n"
)
# Act
diagnostics = render_diagnostics(raw_output, exit_code=1)
# Assert
assert diagnostics == raw_output
def test_extract_diagnostics_returns_empty_for_non_error_output() -> None:
# Arrange
raw_output = "INFO Checking project configured at `/tmp/project/pyrefly.toml`\n"

View File

@ -1,3 +1,4 @@
import logging
from unittest.mock import Mock, patch
import pytest
@ -427,16 +428,20 @@ class TestWorkflowCollaborationService:
repository.delete_leader.assert_not_called()
def test_broadcast_leader_change_logs_emit_errors(
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
self,
service: tuple[WorkflowCollaborationService, Mock, Mock],
caplog: pytest.LogCaptureFixture,
) -> None:
collaboration_service, repository, socketio = service
repository.get_session_sids.return_value = ["sid-1", "sid-2"]
socketio.emit.side_effect = [RuntimeError("boom"), None]
with patch("services.workflow_collaboration_service.logging.exception") as exception_mock:
with caplog.at_level(logging.ERROR):
collaboration_service.broadcast_leader_change("wf-1", "sid-2")
assert exception_mock.call_count == 1
error_records = [record for record in caplog.records if record.levelno == logging.ERROR]
assert len(error_records) == 1
assert "Failed to emit leader status to session sid-1" in caplog.text
def test_broadcast_online_users_sorts_and_emits(
self, service: tuple[WorkflowCollaborationService, Mock, Mock]

View File

@ -28,6 +28,10 @@ pyrefly_args=(
"--project-excludes=tests/"
)
if [[ "${PYREFLY_OUTPUT_FORMAT:-}" == "github" ]]; then
pyrefly_args+=("--output-format=github")
fi
if [[ -f "$EXCLUDES_FILE" ]]; then
while IFS= read -r exclude; do
[[ -z "$exclude" || "${exclude:0:1}" == "#" ]] && continue
@ -36,6 +40,14 @@ if [[ -f "$EXCLUDES_FILE" ]]; then
fi
run_pyrefly() {
if [[ "${PYREFLY_OUTPUT_FORMAT:-}" == "github" ]]; then
set +e
"$@"
local pyrefly_status=$?
set -e
return "$pyrefly_status"
fi
local tmp_output
tmp_output="$(mktemp)"
@ -44,7 +56,7 @@ run_pyrefly() {
local pyrefly_status=$?
set -e
uv run --directory api python libs/pyrefly_diagnostics.py < "$tmp_output"
uv run --directory api python libs/pyrefly_diagnostics.py --status "$pyrefly_status" < "$tmp_output"
rm -f "$tmp_output"
return "$pyrefly_status"
}
@ -62,11 +74,17 @@ fi
run_pyrefly "${pyrefly_command[@]}" || status=$?
if (( ${#target_paths[@]} == 0 )); then
test_containers_args=(
"--summary=none"
"--use-ignore-files=false"
"--config=$TEST_CONTAINERS_CONFIG"
)
if [[ "${PYREFLY_OUTPUT_FORMAT:-}" == "github" ]]; then
test_containers_args+=("--output-format=github")
fi
run_pyrefly \
uv run --directory api --dev pyrefly check \
"--summary=none" \
"--use-ignore-files=false" \
"--config=$TEST_CONTAINERS_CONFIG" \
"${test_containers_args[@]}" \
|| status=$?
fi

View File

@ -0,0 +1,47 @@
<svg width="52" height="75.5" viewBox="0 0 52 75.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#step-tour-arrow-shadow)">
<rect width="2" height="28" transform="translate(25 7.5)" fill="white" fill-opacity="0.9"/>
<rect width="2" height="28" transform="translate(25 7.5)" fill="#E9F0FF"/>
<g filter="url(#step-tour-arrow-dot-shadow)">
<circle cx="26" cy="10.5" r="6" fill="#0033FF"/>
<circle cx="26" cy="10.5" r="5" stroke="white" stroke-width="2"/>
</g>
</g>
<defs>
<filter id="step-tour-arrow-shadow" x="0" y="-5.5" width="52" height="81" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect1_dropShadow_stepTourArrow"/>
<feOffset dy="8"/>
<feGaussianBlur stdDeviation="4"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.03 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_stepTourArrow"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect2_dropShadow_stepTourArrow"/>
<feOffset dy="20"/>
<feGaussianBlur stdDeviation="12"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.08 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_stepTourArrow" result="effect2_dropShadow_stepTourArrow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_stepTourArrow" result="shape"/>
</filter>
<filter id="step-tour-arrow-dot-shadow" x="15" y="0" width="22" height="22" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="0.5" operator="erode" in="SourceAlpha" result="effect1_dropShadow_stepTourArrowDot"/>
<feOffset dy="0.5"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.06 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_stepTourArrowDot"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.5"/>
<feGaussianBlur stdDeviation="2.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.06 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_stepTourArrowDot" result="effect2_dropShadow_stepTourArrowDot"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_stepTourArrowDot" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 13.6667L14 12.3333V3L8 4.33333L2 3V12.3333L8 13.6667ZM8 4.33333V13.6667" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 282 B

View File

@ -1,6 +1,6 @@
{
"prefix": "custom-public",
"lastModified": 1781515983,
"lastModified": 1782516559,
"icons": {
"agent-building-blocks": {
"body": "<path fill=\"#155AEF\" fill-rule=\"evenodd\" d=\"M8.303 1.546c.178-.045.364-.051.544-.017c.23.043.432.167.573.246l3.757 2.113c.12.067.29.156.433.289l.06.06c.12.131.21.288.267.457c.07.215.063.445.063.6V9.56c0 .146.007.36-.056.563q-.055.181-.162.338l-.075.1c-.137.163-.32.274-.442.353l-5.013 3.259c-.135.088-.33.224-.556.282a1.3 1.3 0 0 1-.543.017c-.23-.043-.433-.166-.573-.245l-3.757-2.114c-.136-.077-.34-.182-.493-.35a1.3 1.3 0 0 1-.267-.456C1.993 11.09 2 10.86 2 10.704V6.441c0-.146-.007-.36.055-.563l.043-.118a1.3 1.3 0 0 1 .195-.32l.053-.059c.128-.131.282-.225.389-.294L7.86 1.755c.122-.078.273-.165.443-.209m-4.97 9.158l.001.164l.033.02l.11.062l3.264 1.836v-1.137L3.333 9.732zm4.741.917v1.076l4.464-2.901l.098-.065l.029-.02v-.034l.001-.118v-.923zm-4.74-3.419L6.74 10.12V8.982L3.333 7.066zm4.74.752v1.076l4.592-2.985V5.969zm.51-6.08l-4.631 3.01l3.429 1.93l4.664-3.032l-3.28-1.846l-.15-.082z\" clip-rule=\"evenodd\"/>"
@ -582,6 +582,11 @@
"width": 24,
"height": 24
},
"step-by-step-tour-coachmark-arrow": {
"body": "<g fill=\"none\"><g filter=\"url(#svgID0)\"><path fill=\"#fff\" fill-opacity=\".9\" d=\"M25 7.5h2v28h-2z\"/><path fill=\"#E9F0FF\" d=\"M25 7.5h2v28h-2z\"/><g filter=\"url(#svgID1)\"><circle cx=\"26\" cy=\"10.5\" r=\"6\" fill=\"#03F\"/><circle cx=\"26\" cy=\"10.5\" r=\"5\" stroke=\"#fff\" stroke-width=\"2\"/></g></g><defs><filter id=\"svgID0\" width=\"52\" height=\"81\" x=\"0\" y=\"-5.5\" color-interpolation-filters=\"sRGB\" filterUnits=\"userSpaceOnUse\"><feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"/><feColorMatrix in=\"SourceAlpha\" result=\"hardAlpha\" values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"/><feMorphology in=\"SourceAlpha\" radius=\"4\" result=\"effect1_dropShadow_stepTourArrow\"/><feOffset dy=\"8\"/><feGaussianBlur stdDeviation=\"4\"/><feComposite in2=\"hardAlpha\" operator=\"out\"/><feColorMatrix values=\"0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.03 0\"/><feBlend in2=\"BackgroundImageFix\" result=\"effect1_dropShadow_stepTourArrow\"/><feColorMatrix in=\"SourceAlpha\" result=\"hardAlpha\" values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"/><feMorphology in=\"SourceAlpha\" radius=\"4\" result=\"effect2_dropShadow_stepTourArrow\"/><feOffset dy=\"20\"/><feGaussianBlur stdDeviation=\"12\"/><feComposite in2=\"hardAlpha\" operator=\"out\"/><feColorMatrix values=\"0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.08 0\"/><feBlend in2=\"effect1_dropShadow_stepTourArrow\" result=\"effect2_dropShadow_stepTourArrow\"/><feBlend in=\"SourceGraphic\" in2=\"effect2_dropShadow_stepTourArrow\" result=\"shape\"/></filter><filter id=\"svgID1\" width=\"22\" height=\"22\" x=\"15\" y=\"0\" color-interpolation-filters=\"sRGB\" filterUnits=\"userSpaceOnUse\"><feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"/><feColorMatrix in=\"SourceAlpha\" result=\"hardAlpha\" values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"/><feMorphology in=\"SourceAlpha\" radius=\".5\" result=\"effect1_dropShadow_stepTourArrowDot\"/><feOffset dy=\".5\"/><feGaussianBlur stdDeviation=\"1\"/><feComposite in2=\"hardAlpha\" operator=\"out\"/><feColorMatrix values=\"0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.06 0\"/><feBlend in2=\"BackgroundImageFix\" result=\"effect1_dropShadow_stepTourArrowDot\"/><feColorMatrix in=\"SourceAlpha\" result=\"hardAlpha\" values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"/><feOffset dy=\".5\"/><feGaussianBlur stdDeviation=\"2.5\"/><feComposite in2=\"hardAlpha\" operator=\"out\"/><feColorMatrix values=\"0 0 0 0 0.0352941 0 0 0 0 0.0352941 0 0 0 0 0.0431373 0 0 0 0.06 0\"/><feBlend in2=\"effect1_dropShadow_stepTourArrowDot\" result=\"effect2_dropShadow_stepTourArrowDot\"/><feBlend in=\"SourceGraphic\" in2=\"effect2_dropShadow_stepTourArrowDot\" result=\"shape\"/></filter></defs></g>",
"width": 52,
"height": 75.5
},
"thought-data-set": {
"body": "<g fill=\"none\"><g clip-path=\"url(#svgID0)\"><path stroke=\"#667085\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.25\" d=\"M10.5 2.5C10.5 3.328 8.485 4 6 4s-4.5-.672-4.5-1.5m9 0C10.5 1.672 8.485 1 6 1s-4.5.672-4.5 1.5m9 0v7c0 .83-2 1.5-4.5 1.5s-4.5-.67-4.5-1.5v-7m9 3.5c0 .83-2 1.5-4.5 1.5S1.5 6.83 1.5 6\"/></g><defs><clipPath id=\"svgID0\"><path fill=\"#fff\" d=\"M0 0h12v12H0z\"/></clipPath></defs></g>",
"width": 12,

View File

@ -1,7 +1,7 @@
{
"prefix": "custom-public",
"name": "Dify Custom Public",
"total": 145,
"total": 146,
"version": "0.0.0-private",
"author": {
"name": "LangGenius, Inc.",

View File

@ -1,6 +1,6 @@
{
"prefix": "custom-vender",
"lastModified": 1781515983,
"lastModified": 1782516559,
"icons": {
"agent-v2-access-point": {
"body": "<g fill=\"none\"><path d=\"M7.5 11.25C7.91421 11.25 8.25 11.5858 8.25 12V14.25C8.25 14.6642 7.91421 15 7.5 15C7.08579 15 6.75 14.6642 6.75 14.25V12C6.75 11.5858 7.08579 11.25 7.5 11.25Z\" fill=\"currentColor\"/><path d=\"M2.19653 2.19653C2.48937 1.90372 2.96418 1.90382 3.25708 2.19653L8.03027 6.96973C8.09162 7.03108 8.13966 7.10082 8.17529 7.1748C8.19164 7.20869 8.20587 7.24378 8.21704 7.28027C8.24638 7.37633 8.25641 7.477 8.24634 7.57617C8.23743 7.66451 8.21216 7.74788 8.17529 7.82446C8.13963 7.89868 8.09176 7.96874 8.03027 8.03027L3.25708 12.8035C2.96419 13.096 2.48932 13.0962 2.19653 12.8035C1.90394 12.5107 1.90405 12.0358 2.19653 11.7429L5.68945 8.25H0.75C0.335786 8.25 0 7.91421 0 7.5C0 7.08579 0.335786 6.75 0.75 6.75H5.68945L2.19653 3.25708C1.90389 2.96423 1.90388 2.48937 2.19653 2.19653Z\" fill=\"currentColor\"/><path d=\"M10.1521 10.1521C10.445 9.85921 10.9198 9.85921 11.2126 10.1521L12.8035 11.7429C13.096 12.0358 13.0962 12.5107 12.8035 12.8035C12.5107 13.0962 12.0358 13.096 11.7429 12.8035L10.1521 11.2126C9.85921 10.9198 9.85922 10.445 10.1521 10.1521Z\" fill=\"currentColor\"/><path d=\"M14.25 6.75C14.6642 6.75 15 7.08579 15 7.5C15 7.91421 14.6642 8.25 14.25 8.25H12C11.5858 8.25 11.25 7.91421 11.25 7.5C11.25 7.08579 11.5858 6.75 12 6.75H14.25Z\" fill=\"currentColor\"/><path d=\"M11.7422 2.19653C12.035 1.90387 12.5098 1.90406 12.8027 2.19653C13.0956 2.4894 13.0955 2.96419 12.8027 3.25708L11.2119 4.8479C10.919 5.14079 10.4443 5.1408 10.1514 4.8479C9.85883 4.55497 9.85858 4.08013 10.1514 3.78735L11.7422 2.19653Z\" fill=\"currentColor\"/><path d=\"M7.5 0C7.91421 0 8.25 0.335786 8.25 0.75V3C8.25 3.41421 7.91421 3.75 7.5 3.75C7.08579 3.75 6.75 3.41421 6.75 3V0.75C6.75 0.335786 7.08579 0 7.5 0Z\" fill=\"currentColor\"/></g>",
@ -433,6 +433,9 @@
"width": 12,
"height": 12
},
"line-education-lesson-open-01": {
"body": "<g fill=\"none\"><path d=\"M8 13.6667L14 12.3333V3L8 4.33333L2 3V12.3333L8 13.6667ZM8 4.33333V13.6667\" stroke=\"currentColor\" stroke-width=\"1.33333\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></g>"
},
"line-files-copy": {
"body": "<g fill=\"none\"><path d=\"M10.6665 2.66634H11.9998C12.3535 2.66634 12.6926 2.80682 12.9426 3.05687C13.1927 3.30691 13.3332 3.64605 13.3332 3.99967V13.333C13.3332 13.6866 13.1927 14.0258 12.9426 14.2758C12.6926 14.5259 12.3535 14.6663 11.9998 14.6663H3.99984C3.64622 14.6663 3.30708 14.5259 3.05703 14.2758C2.80698 14.0258 2.6665 13.6866 2.6665 13.333V3.99967C2.6665 3.64605 2.80698 3.30691 3.05703 3.05687C3.30708 2.80682 3.64622 2.66634 3.99984 2.66634H5.33317M5.99984 1.33301H9.99984C10.368 1.33301 10.6665 1.63148 10.6665 1.99967V3.33301C10.6665 3.7012 10.368 3.99967 9.99984 3.99967H5.99984C5.63165 3.99967 5.33317 3.7012 5.33317 3.33301V1.99967C5.33317 1.63148 5.63165 1.33301 5.99984 1.33301Z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></g>"
},

View File

@ -1,7 +1,7 @@
{
"prefix": "custom-vender",
"name": "Dify Custom Vender",
"total": 326,
"total": 327,
"version": "0.0.0-private",
"author": {
"name": "LangGenius, Inc.",

View File

@ -3,6 +3,7 @@ import type { ModelProvider } from './declarations'
import type { PluginDetail } from '@/app/components/plugins/types'
import { Trans, useTranslation } from 'react-i18next'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { IS_CLOUD_EDITION } from '@/config'
import InstallFromMarketplace from './install-from-marketplace'
import ProviderAddedCard from './provider-added-card'
@ -80,7 +81,9 @@ function EmptyProviderState({
<a
href="#model-provider-marketplace"
className="system-xs-medium text-text-accent hover:underline"
/>
>
{t('mainNav.marketplace', { ns: 'common' })}
</a>
),
}}
/>
@ -134,7 +137,7 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
return (
<div className="flex flex-col gap-2">
{IS_CLOUD_EDITION && (
<div>
<div data-step-by-step-tour-target={STEP_BY_STEP_TOUR_TARGETS.integration}>
<QuotaPanel providers={providers} />
</div>
)}

View File

@ -13,6 +13,7 @@ import { Plan } from '@/app/components/billing/type'
import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '@/app/components/explore/learn-dify/storage'
import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { STEP_BY_STEP_TOUR_STORAGE_KEY } from '@/app/components/step-by-step-tour/constants'
import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
@ -62,6 +63,35 @@ vi.mock('@/next/navigation', async (importOriginal) => {
}
})
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return {
...actual,
...createReactI18nextMock({
'common.stepByStepTour.title': 'Get to know Dify',
'common.stepByStepTour.duration': 'A quick tour — about 5 minutes',
'common.stepByStepTour.skip': 'Skip',
'common.stepByStepTour.minimize': 'Minimize tour',
'common.stepByStepTour.restore': 'Open step-by-step tour',
'common.stepByStepTour.learnMore': 'Learn more',
'common.stepByStepTour.tasks.home.title': 'Try a Learn Dify lesson',
'common.stepByStepTour.tasks.home.description': 'Open a hands-on lesson from Learn Dify to see Dify in action.',
'common.stepByStepTour.tasks.home.primaryActionLabel': 'Show me',
'common.stepByStepTour.tasks.studio.title': 'Manage your apps in Studio',
'common.stepByStepTour.tasks.studio.description': 'All your apps live in Studio — edit, organize, and publish them here.',
'common.stepByStepTour.tasks.studio.primaryActionLabel': 'Take a look',
'common.stepByStepTour.tasks.knowledge.title': 'Add your own data',
'common.stepByStepTour.tasks.knowledge.description': 'Build a knowledge base so your apps answer from your documents.',
'common.stepByStepTour.tasks.knowledge.primaryActionLabel': 'Take a look',
'common.stepByStepTour.tasks.integration.title': 'Explore integrations',
'common.stepByStepTour.tasks.integration.description': 'Models, tools, data sources & more — explore what you can connect.',
'common.stepByStepTour.tasks.integration.primaryActionLabel': 'Take a look',
}),
}
})
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
const currentWorkspaceQueryKey = ['console', 'workspaces', 'current', 'post'] as const
@ -919,6 +949,65 @@ describe('MainNav', () => {
expect(mockPush).not.toHaveBeenCalled()
})
it('shows Step-by-step Tour switch in help menu and stores the current workspace disable override', async () => {
renderMainNav({ enable_learn_app: true })
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' }))
const stepByStepTourItem = await screen.findByRole('menuitemcheckbox', { name: 'common.mainNav.help.stepByStepTour' })
expect(stepByStepTourItem).toHaveAttribute('aria-checked', 'true')
fireEvent.click(stepByStepTourItem)
await waitFor(() => {
expect(localStorage.getItem(STEP_BY_STEP_TOUR_STORAGE_KEY)).toContain('"manuallyDisabledWorkspaceIds":["workspace-1"]')
})
expect(screen.queryByRole('region', { name: 'Get to know Dify' })).not.toBeInTheDocument()
expect(screen.getByRole('menu')).toBeInTheDocument()
expect(mockPush).not.toHaveBeenCalled()
})
it('restores the expanded Step-by-step Tour after toggling it off and on again', async () => {
renderMainNav({ enable_learn_app: true })
expect(await screen.findByRole('region', { name: 'Get to know Dify' })).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' }))
const stepByStepTourItem = await screen.findByRole('menuitemcheckbox', { name: 'common.mainNav.help.stepByStepTour' })
fireEvent.click(stepByStepTourItem)
await waitFor(() => {
expect(screen.queryByRole('region', { name: 'Get to know Dify' })).not.toBeInTheDocument()
})
fireEvent.click(stepByStepTourItem)
await waitFor(() => {
expect(screen.getByRole('region', { name: 'Get to know Dify' })).toBeVisible()
})
expect(localStorage.getItem(STEP_BY_STEP_TOUR_STORAGE_KEY)).toContain('"manuallyDisabledWorkspaceIds":[]')
})
it('keeps Step-by-step Tour mini mounted when detail navigation is collapsed during walkthrough', async () => {
mockPathname = '/app/app-1/overview'
useAppStore.getState().setAppDetail({
id: 'app-1',
} as NonNullable<ReturnType<typeof useAppStore.getState>['appDetail']>)
localStorage.setItem(DETAIL_SIDEBAR_STORAGE_KEY, 'collapse')
localStorage.setItem(STEP_BY_STEP_TOUR_STORAGE_KEY, JSON.stringify({
activeTaskId: 'integration',
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: false,
completedTaskIds: ['home', 'studio', 'knowledge'],
skipped: false,
}))
renderMainNav({ enable_learn_app: true })
expect(await screen.findByRole('button', { name: 'Open step-by-step tour' })).toBeInTheDocument()
})
it('hides Learn Dify switch in help menu when learn app is disabled', async () => {
renderMainNav({ enable_learn_app: false })
@ -937,6 +1026,7 @@ describe('MainNav', () => {
'common.mainNav.help.docs',
'common.userProfile.roadmap',
'common.mainNav.help.learnDify',
'common.mainNav.help.stepByStepTour',
'common.userProfile.compliance',
'common.userProfile.forum',
'common.userProfile.community',

View File

@ -12,6 +12,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Switch } from '@langgenius/dify-ui/switch'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -20,6 +21,10 @@ import AccountAbout from '@/app/components/header/account-about'
import Compliance from '@/app/components/header/account-dropdown/compliance'
import { ExternalLinkIndicator, MenuItemContent } from '@/app/components/header/account-dropdown/menu-item-content'
import GithubStar from '@/app/components/header/github-star'
import {
useSetStepByStepTourAccountState,
useStepByStepTourAccountStateValue,
} from '@/app/components/step-by-step-tour/storage'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useDocLink } from '@/context/i18n'
@ -44,6 +49,30 @@ const defaultTriggerIcon = (
</svg>
)
const addWorkspaceId = (workspaceIds: string[], workspaceId: string) => {
if (workspaceIds.includes(workspaceId))
return workspaceIds
return [...workspaceIds, workspaceId]
}
const removeWorkspaceId = (workspaceIds: string[], workspaceId: string) =>
workspaceIds.filter(id => id !== workspaceId)
const MenuSwitchIndicator = ({
checked,
}: {
checked: boolean
}) => (
<Switch
checked={checked}
readOnly
aria-hidden="true"
tabIndex={-1}
className="pointer-events-none"
/>
)
const HelpMenu = ({
triggerIcon = defaultTriggerIcon,
triggerClassName,
@ -51,12 +80,37 @@ const HelpMenu = ({
const { t } = useTranslation()
const docLink = useDocLink()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { langGeniusVersionInfo, isCurrentWorkspaceOwner } = useAppContext()
const { langGeniusVersionInfo, isCurrentWorkspaceOwner, currentWorkspace } = useAppContext()
const learnDifyHidden = useLearnDifyHiddenValue()
const setLearnDifyHidden = useSetLearnDifyHidden()
const stepByStepTourAccountState = useStepByStepTourAccountStateValue()
const setStepByStepTourAccountState = useSetStepByStepTourAccountState()
const [aboutVisible, setAboutVisible] = useState(false)
const [open, setOpen] = useState(false)
const shouldShowLearnDifySwitch = systemFeatures.enable_learn_app
const currentWorkspaceId = currentWorkspace.id
const stepByStepTourEnabled = !stepByStepTourAccountState.skipped
&& !stepByStepTourAccountState.manuallyDisabledWorkspaceIds.includes(currentWorkspaceId)
&& (
stepByStepTourAccountState.firstWorkspaceId === currentWorkspaceId
|| stepByStepTourAccountState.manuallyEnabledWorkspaceIds.includes(currentWorkspaceId)
)
const handleStepByStepTourCheckedChange = (checked: boolean) => {
setStepByStepTourAccountState({
...stepByStepTourAccountState,
skipped: checked ? false : stepByStepTourAccountState.skipped,
manuallyEnabledWorkspaceIds: checked
? addWorkspaceId(stepByStepTourAccountState.manuallyEnabledWorkspaceIds, currentWorkspaceId)
: removeWorkspaceId(stepByStepTourAccountState.manuallyEnabledWorkspaceIds, currentWorkspaceId),
manuallyDisabledWorkspaceIds: checked
? removeWorkspaceId(stepByStepTourAccountState.manuallyDisabledWorkspaceIds, currentWorkspaceId)
: addWorkspaceId(stepByStepTourAccountState.manuallyDisabledWorkspaceIds, currentWorkspaceId),
})
if (checked)
setOpen(false)
}
if (systemFeatures.branding.enabled)
return null
@ -107,20 +161,21 @@ const HelpMenu = ({
<span className="min-w-0 flex-1 truncate px-1 py-0.5 system-md-regular text-text-secondary">
{t('mainNav.help.learnDify', { ns: 'common' })}
</span>
<span
aria-hidden
className={cn(
'relative inline-flex h-4 w-7 shrink-0 items-center rounded-[5px] p-0.5 transition-colors',
!learnDifyHidden ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked',
)}
>
<span
className={cn(
'block h-3 w-2.5 rounded-[3px] bg-components-toggle-knob shadow-sm transition-transform',
!learnDifyHidden && 'translate-x-3.5',
)}
/>
<MenuSwitchIndicator checked={!learnDifyHidden} />
</DropdownMenuCheckboxItem>
)}
{IS_CLOUD_EDITION && (
<DropdownMenuCheckboxItem
checked={stepByStepTourEnabled}
closeOnClick={false}
className="mx-0 h-8 gap-1 px-0 py-1 pr-2 pl-3"
onCheckedChange={handleStepByStepTourCheckedChange}
>
<span aria-hidden className="i-custom-vender-line-education-book-open-01 size-4 shrink-0 text-text-tertiary" />
<span className="min-w-0 flex-1 truncate px-1 py-0.5 system-md-regular text-text-secondary">
{t('mainNav.help.stepByStepTour', { ns: 'common' })}
</span>
<MenuSwitchIndicator checked={stepByStepTourEnabled} />
</DropdownMenuCheckboxItem>
)}
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}

View File

@ -14,6 +14,7 @@ import DatasetDetailTop from '@/app/components/app-sidebar/dataset-detail-top'
import { useStore as useAppStore } from '@/app/components/app/store'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import EnvNav from '@/app/components/header/env-nav'
import StepByStepTourMount from '@/app/components/step-by-step-tour/mount'
import { useAppContext } from '@/context/app-context'
import { AgentDetailSection, AgentDetailTop } from '@/features/agent-v2/agent-detail/navigation'
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
@ -311,6 +312,13 @@ const MainNav = ({
</div>
)}
</div>
<StepByStepTourMount className={cn(
'relative z-40 shrink-0 overflow-visible',
bottomNavigationExpanded
? 'px-2 pb-2'
: 'px-0 pb-2',
)}
/>
<div className={cn(
!bottomNavigationExpanded
? 'flex w-full shrink-0 flex-col items-center gap-0.5 rounded-lg px-2 pt-1 pb-3'

View File

@ -0,0 +1,268 @@
# Step-by-step Tour Frontend Design
This document defines the target frontend infrastructure for the Dify Cloud new-user step-by-step tour. It should be treated as the product and technical source of truth for the frontend work, not as a description of whichever code happens to exist today.
## Product Definition
The tour is account-level onboarding with one workspace as the initial context.
- Product surface: Dify Cloud only.
- Auto-show audience: newly registered accounts only.
- Auto-show workspace: only the first workspace the new account enters after registration.
- First workspace can be self-created or invited.
- User role does not affect auto-show eligibility.
- Switching to another workspace does not auto-show the tour by default.
- Any workspace can manually show the tour from Help menu.
- Existing account invited into a new workspace does not auto-show.
- Important locales for the first frontend slice: `en-US`, `zh-Hans`, `ja-JP`.
- English duration copy should say `about 5 minutes`.
## Frontend Principle
Frontend should model the feature as an account-level onboarding controller plus workspace-aware presentation.
Do not model this as `Record<workspaceId, state>` with every workspace defaulting to enabled. That creates the wrong product behavior: a user who switches workspaces would see the tour again by default.
The frontend can temporarily use a local placeholder while backend state is unavailable, but the placeholder should mirror the final account-level shape:
```ts
type StepByStepTourAccountState = {
firstWorkspaceId?: string
manuallyEnabledWorkspaceIds: string[]
manuallyDisabledWorkspaceIds: string[]
minimized: boolean
completedTaskIds: StepByStepTourTaskId[]
skipped: boolean
}
```
Derived visibility:
```ts
const enabledForCurrentWorkspace = !skipped
&& !manuallyDisabledWorkspaceIds.includes(currentWorkspaceId)
&& (firstWorkspaceId === currentWorkspaceId || manuallyEnabledWorkspaceIds.includes(currentWorkspaceId))
```
`manuallyEnabledWorkspaceIds` and `manuallyDisabledWorkspaceIds` are workspace-level overrides on top of account-level onboarding:
- `manuallyEnabledWorkspaceIds`: workspaces where the user explicitly turns the tour on from Help menu. This is how a non-first workspace can show the tour.
- `manuallyDisabledWorkspaceIds`: workspaces where the user explicitly turns the tour off from Help menu. This lets the first workspace stop showing without changing `firstWorkspaceId`.
- `skipped`: account-level hide state. It hides the tour across workspaces until the user manually re-enables it.
Temporary placeholder behavior:
- Pretend the current account is a new user.
- The first workspace seen by the browser becomes `firstWorkspaceId`.
- That workspace auto-shows.
- Later workspaces do not auto-show unless manually enabled.
- This is only a frontend placeholder and should be replaced by backend account state later.
## Target Frontend Architecture
```txt
Common layout
-> StepByStepTourMount
-> useStepByStepTourController(currentWorkspaceId)
-> account-level source adapter
-> temporary local placeholder now
-> backend query/mutation later
-> derived visibility
-> task routing
-> task completion commands
-> FloatingChecklist
-> SpotlightLayer
HelpMenu
-> useStepByStepTourController(currentWorkspaceId)
-> enable/disable current workspace manually
```
Recommended ownership:
- `StepByStepTourMount`: route integration and high-level composition.
- `useStepByStepTourController`: single public hook for visibility, commands, and derived state.
- `storage.ts` or future `state.ts`: account-level state adapter. Today local placeholder, later API-backed.
- `floating-widget.tsx`: visual checklist only. No product eligibility logic.
- `target-registry.ts`: maps tasks to routes and DOM targets.
- `spotlight-layer.tsx`: overlay, target measurement, click-through rules, and fallback target rendering.
- `completion.ts`: task completion rules.
Avoid passing raw backend/local state into visual components. Visual components should receive already-derived props such as `visible`, `completedTaskIds`, `minimized`, and callbacks.
Data flow contract:
- `storage.ts` owns raw persisted account state only.
- `useStepByStepTourController` reads the source adapter, derives workspace visibility, derives task presentation state, and exposes commands.
- `StepByStepTourMount` gets the current workspace and route context, calls the controller, and passes derived props to visual components.
- `FloatingChecklist` renders task rows and calls controller commands such as start, minimize, and skip. It should not read localStorage or decide product eligibility.
- `HelpMenu` calls the same controller to enable or disable the current workspace manually.
## Product Components
### Floating Checklist
Purpose: compact, persistent entry point for the tour.
Required behavior:
- Show title, duration, progress, task rows, minimize, and skip.
- Task CTAs must be clickable.
- Clicking a CTA should route to the relevant surface and start the task target flow.
- Minimize keeps a small entry visible.
- Skip hides the tour for the account until manually re-enabled.
### Help Menu Toggle
Purpose: manual recovery and workspace opt-in.
Required behavior:
- Cloud only.
- Reflect whether the tour is enabled for the current workspace.
- Turning on enables the tour for the current workspace even if it is not the first workspace.
- Turning off disables the tour for the current workspace without changing the account's first workspace.
### Spotlight Layer
Purpose: guide attention to the actual next action after a task starts.
Required behavior:
- Register target elements by stable data attributes.
- Wait for route transitions and async rendering.
- Highlight a target or a defined fallback empty state.
- Allow click-through only when the underlying action is safe and intended.
- Never trap the user if the target is unavailable.
### Task Target Registry
Purpose: keep product task definitions declarative.
Suggested shape:
```ts
type StepByStepTourTaskDefinition = {
id: StepByStepTourTaskId
route: string
target: string
fallbackTarget?: string
canClickThrough: boolean
permissionFallback?: 'show-parent-empty-state' | 'show-disabled-reason'
}
```
Expected tasks:
- `home`: Learn Dify or Home learning surface.
- `studio`: Studio/apps page.
- `knowledge`: Knowledge/datasets page.
- `integration`: Integrations page.
### Completion Controller
Purpose: persist progress based on product-approved completion rules.
Completion should be explicit per task. Do not assume every CTA click means completion unless PM accepts "visited" as completion.
Possible first rules:
- `home`: completed after user opens the Learn Dify lesson or lands on the chosen learning surface.
- `studio`: completed after user visits Studio/apps page.
- `knowledge`: completed after user visits Knowledge page, opens a dataset, or creates one. PM decision needed.
- `integration`: completed after user visits Integrations page or reaches a valid fallback empty state. PM decision needed.
## Placeholder Versus Future Backend
Backend is out of scope for this frontend branch, so frontend should use a placeholder that mimics backend behavior.
Placeholder source:
- localStorage
- account-level shape
- assumes current account is newly registered
- captures the first seen workspace as `firstWorkspaceId`
Future backend source:
- generated API query/mutation
- account-level eligibility decided by backend
- cross-device persistence for skipped, completed, and manually enabled workspaces
The frontend adapter should be swappable:
```txt
useStepByStepTourController
-> useStepByStepTourSource
-> local placeholder now
-> backend API later
```
The rest of the UI should not care which source is active.
## Route And Permission Rules
Initial route rules:
- Show in common shell surfaces where onboarding is useful.
- Hide on app detail routes such as `/app/`.
- Hide during plugin install routes such as `/installed/`.
Permission recommendation:
- Do not hide the whole tour because one task is unavailable.
- For this frontend slice, do not hide unavailable tasks. Show the task row in a disabled state with a reason when the user's role cannot perform it.
- Prefer task-level fallbacks:
- route to parent page and highlight empty state
- disable row with reason
Open questions:
- Should account settings, billing, invite flow, and provider setup hide the tour?
- Should manual enable show on all common pages or only Home?
- Should skip mean "never auto-show again" or "hide until Help menu re-enables"?
- Which tasks are visit-based versus action-based completion?
## Implementation Slices
### Slice 1: Frontend Shell With Correct Placeholder
- Account-level placeholder state.
- First seen workspace auto-shows.
- Other workspaces default hidden.
- Help menu can manually enable current workspace.
- Floating checklist renders and task CTAs route.
- Key locale copy exists.
- Current widget implementation can receive copy through props first; full locale wiring can follow after the visual and interaction contract is approved.
### Slice 2: Controller Cleanup
- Introduce `useStepByStepTourController`.
- Move task route mapping out of mount component.
- Keep visual widget product-logic-free.
### Slice 3: Spotlight Infrastructure
- Add target registry.
- Add target data attributes to product surfaces.
- Add overlay with fallback target behavior.
### Slice 4: Completion Rules
- Add task-specific completion detection.
- Persist completed tasks through the source adapter.
### Slice 5: Backend Adapter
- Replace local placeholder with generated API query/mutation.
- Keep public controller shape stable.
## Review Checklist
- Does the tour auto-show only for the first workspace in the placeholder?
- Does switching workspace default to hidden?
- Can Help menu manually enable the tour in a non-first workspace?
- Does disabling from Help menu affect only the current workspace?
- Does skip hide the account-level tour until manual re-enable?
- Are visual components free of eligibility logic?
- Is localStorage clearly treated as a temporary source adapter?

View File

@ -0,0 +1,348 @@
import type { AppContextValue } from '@/context/app-context'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Plan } from '@/app/components/billing/type'
import { STEP_BY_STEP_TOUR_STORAGE_KEY } from '../constants'
import StepByStepTourMount from '../mount'
import { STEP_BY_STEP_TOUR_TARGETS } from '../target-registry'
const mockRouterPush = vi.fn()
let mockPathname = '/apps'
const setViewportSize = ({
height,
width,
}: {
height: number
width: number
}) => {
Object.defineProperty(window, 'innerHeight', {
configurable: true,
value: height,
})
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: width,
})
}
vi.mock('@/config', () => ({
IS_CLOUD_EDITION: true,
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
useRouter: () => ({ push: mockRouterPush }),
}))
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return {
...actual,
...createReactI18nextMock({
'common.stepByStepTour.title': 'Get to know Dify',
'common.stepByStepTour.duration': 'A quick tour — about 5 minutes',
'common.stepByStepTour.skip': 'Skip',
'common.stepByStepTour.minimize': 'Minimize tour',
'common.stepByStepTour.restore': 'Open step-by-step tour',
'common.stepByStepTour.learnMore': 'Learn more',
'common.stepByStepTour.tasks.home.title': 'Try a Learn Dify lesson',
'common.stepByStepTour.tasks.home.description': 'Open a hands-on lesson from Learn Dify to see Dify in action.',
'common.stepByStepTour.tasks.home.primaryActionLabel': 'Show me',
'common.stepByStepTour.tasks.studio.title': 'Manage your apps in Studio',
'common.stepByStepTour.tasks.studio.description': 'All your apps live in Studio — edit, organize, and publish them here.',
'common.stepByStepTour.tasks.studio.primaryActionLabel': 'Take a look',
'common.stepByStepTour.tasks.knowledge.title': 'Add your own data',
'common.stepByStepTour.tasks.knowledge.description': 'Build a knowledge base so your apps answer from your documents.',
'common.stepByStepTour.tasks.knowledge.primaryActionLabel': 'Take a look',
'common.stepByStepTour.tasks.integration.title': 'Explore integrations',
'common.stepByStepTour.tasks.integration.description': 'Models, tools, data sources & more — explore what you can connect.',
'common.stepByStepTour.tasks.integration.primaryActionLabel': 'Take a look',
}),
}
})
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: {
id: 'workspace-1',
name: 'Solar Studio',
plan: Plan.sandbox,
status: 'normal',
role: 'owner',
created_at: 0,
providers: [],
trial_credits: 0,
trial_credits_used: 0,
next_credit_reset_date: 0,
},
} satisfies Partial<AppContextValue>),
}))
describe('StepByStepTourMount', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPathname = '/apps'
localStorage.clear()
setViewportSize({ height: 768, width: 1024 })
globalThis.ResizeObserver = class ResizeObserver {
constructor(_callback: ResizeObserverCallback) {}
observe() {}
unobserve() {}
disconnect() {}
} as typeof ResizeObserver
})
it('captures the first workspace and renders the floating checklist by default', async () => {
render(<StepByStepTourMount />)
expect(await screen.findByRole('region', { name: 'Get to know Dify' })).toBeInTheDocument()
await waitFor(() => {
const state = JSON.parse(localStorage.getItem(STEP_BY_STEP_TOUR_STORAGE_KEY)!)
expect(state.firstWorkspaceId).toBe('workspace-1')
})
})
it('renders the floating checklist when the current workspace is manually enabled', async () => {
localStorage.setItem(STEP_BY_STEP_TOUR_STORAGE_KEY, JSON.stringify({
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: false,
completedTaskIds: [],
skipped: false,
}))
render(<StepByStepTourMount />)
expect(await screen.findByRole('region', { name: 'Get to know Dify' })).toBeInTheDocument()
expect(screen.getByText('A quick tour — about 5 minutes')).toBeInTheDocument()
})
it('renders the expanded checklist in the shared popover layer', async () => {
localStorage.setItem(STEP_BY_STEP_TOUR_STORAGE_KEY, JSON.stringify({
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: false,
completedTaskIds: [],
skipped: false,
}))
render(<StepByStepTourMount />)
const checklist = await screen.findByRole('region', { name: 'Get to know Dify' })
const popoverPopup = checklist.parentElement
const popoverPositioner = popoverPopup?.parentElement
expect(checklist.closest('[data-base-ui-portal]')).toBeInTheDocument()
expect(popoverPositioner).toHaveClass('z-50')
expect(popoverPositioner).toHaveAttribute('data-side', 'top')
expect(popoverPositioner).toHaveAttribute('data-align', 'start')
expect(popoverPopup).toHaveClass('max-h-[calc(100vh-16px)]', 'overflow-y-auto')
})
it('starts the integration guide instead of immediately completing the task', async () => {
localStorage.setItem(STEP_BY_STEP_TOUR_STORAGE_KEY, JSON.stringify({
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: false,
completedTaskIds: ['home', 'studio', 'knowledge'],
skipped: false,
}))
render(<StepByStepTourMount />)
fireEvent.click(await screen.findByRole('button', { name: 'Take a look' }))
await waitFor(() => {
const state = JSON.parse(localStorage.getItem(STEP_BY_STEP_TOUR_STORAGE_KEY)!)
expect(state.activeTaskId).toBe('integration')
expect(state.completedTaskIds).toEqual(['home', 'studio', 'knowledge'])
expect(state.minimized).toBe(true)
})
expect(mockRouterPush).toHaveBeenCalledWith('/integrations/model-provider')
})
it('allows users to toggle task completion from the checklist status control', async () => {
localStorage.setItem(STEP_BY_STEP_TOUR_STORAGE_KEY, JSON.stringify({
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: false,
completedTaskIds: ['home'],
skipped: false,
}))
render(<StepByStepTourMount />)
fireEvent.click(await screen.findByRole('button', { name: 'Mark Manage your apps in Studio complete' }))
await waitFor(() => {
const state = JSON.parse(localStorage.getItem(STEP_BY_STEP_TOUR_STORAGE_KEY)!)
expect(state.completedTaskIds).toEqual(['home', 'studio'])
})
fireEvent.click(screen.getByRole('button', { name: 'Mark Try a Learn Dify lesson incomplete' }))
await waitFor(() => {
const state = JSON.parse(localStorage.getItem(STEP_BY_STEP_TOUR_STORAGE_KEY)!)
expect(state.completedTaskIds).toEqual(['studio'])
})
})
it('renders the active guide against the integration target and completes it from Got it', async () => {
mockPathname = '/integrations/model-provider'
localStorage.setItem(STEP_BY_STEP_TOUR_STORAGE_KEY, JSON.stringify({
activeTaskId: 'integration',
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: true,
completedTaskIds: ['home', 'studio', 'knowledge'],
skipped: false,
}))
let targetTop = 114
const target = document.createElement('div')
target.dataset.stepByStepTourTarget = STEP_BY_STEP_TOUR_TARGETS.integration
target.getBoundingClientRect = () => ({
bottom: targetTop + 64,
height: 64,
left: 472,
right: 1484,
top: targetTop,
width: 1012,
x: 472,
y: targetTop,
toJSON: () => ({}),
})
document.body.appendChild(target)
try {
render(<StepByStepTourMount />)
expect(await screen.findByRole('region', { name: 'Keep tools up to date' })).toBeInTheDocument()
const minimizedTourButton = screen.getByRole('button', { name: 'Open step-by-step tour' })
expect(minimizedTourButton).toBeInTheDocument()
expect(minimizedTourButton).not.toHaveClass('z-50')
expect(screen.getByText('4 of 4')).toBeInTheDocument()
expect(document.body.querySelector('[data-step-by-step-tour-backdrop]')).toBeInTheDocument()
const highlight = document.body.querySelector('[data-step-by-step-tour-highlight]') as HTMLElement
const coachmark = document.body.querySelector('[data-step-by-step-tour-coachmark]') as HTMLElement
const arrow = coachmark.querySelector('[aria-hidden="true"]') as HTMLElement
expect(highlight).toHaveStyle({ top: '114px' })
expect(coachmark).toHaveStyle({ left: '472px', top: '198px' })
expect(arrow).toHaveStyle({ left: '28px' })
targetTop = 126
await waitFor(() => {
expect(highlight).toHaveStyle({ top: '126px' })
expect(coachmark).toHaveStyle({ top: '210px' })
})
setViewportSize({ height: 768, width: 600 })
window.dispatchEvent(new Event('resize'))
await waitFor(() => {
expect(coachmark).toHaveStyle({ left: '240px' })
expect(arrow).toHaveStyle({ left: '260px' })
})
fireEvent.click(screen.getByRole('button', { name: 'Got it' }))
await waitFor(() => {
const state = JSON.parse(localStorage.getItem(STEP_BY_STEP_TOUR_STORAGE_KEY)!)
expect(state.activeTaskId).toBeUndefined()
expect(state.completedTaskIds).toEqual(['home', 'studio', 'knowledge', 'integration'])
expect(state.minimized).toBe(false)
})
expect(screen.getByRole('region', { name: 'Get to know Dify' })).toBeInTheDocument()
}
finally {
target.remove()
}
})
it('returns to the minimized tour when skipping a single active guide', async () => {
mockPathname = '/integrations/model-provider'
localStorage.setItem(STEP_BY_STEP_TOUR_STORAGE_KEY, JSON.stringify({
activeTaskId: 'integration',
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: true,
completedTaskIds: ['home', 'studio', 'knowledge'],
skipped: false,
}))
const target = document.createElement('div')
target.dataset.stepByStepTourTarget = STEP_BY_STEP_TOUR_TARGETS.integration
target.getBoundingClientRect = () => ({
bottom: 178,
height: 64,
left: 472,
right: 1484,
top: 114,
width: 1012,
x: 472,
y: 114,
toJSON: () => ({}),
})
document.body.appendChild(target)
try {
render(<StepByStepTourMount />)
expect(await screen.findByRole('region', { name: 'Keep tools up to date' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Skip' }))
await waitFor(() => {
const state = JSON.parse(localStorage.getItem(STEP_BY_STEP_TOUR_STORAGE_KEY)!)
expect(state.activeTaskId).toBeUndefined()
expect(state.minimized).toBe(true)
expect(state.skipped).toBe(false)
expect(state.manuallyEnabledWorkspaceIds).toEqual(['workspace-1'])
})
expect(screen.getByRole('button', { name: 'Open step-by-step tour' })).toBeInTheDocument()
expect(screen.queryByRole('region', { name: 'Keep tools up to date' })).not.toBeInTheDocument()
}
finally {
target.remove()
}
})
it('keeps the minimized tour control visible when an active guide target is unavailable', async () => {
mockPathname = '/app/test-app/overview'
localStorage.setItem(STEP_BY_STEP_TOUR_STORAGE_KEY, JSON.stringify({
activeTaskId: 'integration',
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: true,
completedTaskIds: ['home', 'studio', 'knowledge'],
skipped: false,
}))
render(<StepByStepTourMount />)
expect(await screen.findByRole('button', { name: 'Open step-by-step tour' })).toBeInTheDocument()
})
it('keeps the tour collapsed during an active guide even if the saved widget state is expanded', async () => {
mockPathname = '/integrations/model-provider'
localStorage.setItem(STEP_BY_STEP_TOUR_STORAGE_KEY, JSON.stringify({
activeTaskId: 'integration',
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: false,
completedTaskIds: ['home', 'studio', 'knowledge'],
skipped: false,
}))
render(<StepByStepTourMount />)
expect(await screen.findByRole('button', { name: 'Open step-by-step tour' })).toBeInTheDocument()
expect(screen.queryByRole('region', { name: 'Get to know Dify' })).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,94 @@
'use client'
import type { CSSProperties } from 'react'
import type { StepByStepTourGuide } from './target-registry'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useStepByStepTourCoachmarkPosition } from './use-coachmark-position'
import { useStepByStepTourTargetRect } from './use-target-rect'
type StepByStepTourCoachmarkProps = {
guide: StepByStepTourGuide
targetElement: HTMLElement
stepLabel: string
skipLabel: string
learnMoreHref?: string
onSkip: () => void
onComplete: () => void
}
export function StepByStepTourCoachmark({
guide,
targetElement,
stepLabel,
skipLabel,
learnMoreHref,
onSkip,
onComplete,
}: StepByStepTourCoachmarkProps) {
const targetRect = useStepByStepTourTargetRect(targetElement)
const coachmarkPosition = useStepByStepTourCoachmarkPosition(targetRect)
const highlightStyle: CSSProperties = {
height: targetRect.height,
left: targetRect.left,
top: targetRect.top,
width: targetRect.width,
}
return (
<>
<div
aria-hidden="true"
data-step-by-step-tour-backdrop=""
className="fixed inset-0 z-50 cursor-default bg-transparent"
/>
<div
aria-hidden="true"
data-step-by-step-tour-highlight=""
className="pointer-events-none fixed z-50 rounded-xl shadow-[0_0_0_9999px_rgb(15_23_42/0.58)]"
style={highlightStyle}
/>
<div
className="fixed z-50 w-[352px] max-w-[calc(100vw-16px)]"
data-step-by-step-tour-coachmark=""
style={coachmarkPosition.bubbleStyle}
>
<div className="pointer-events-none absolute -top-6 h-7 w-0.5" style={coachmarkPosition.arrowStyle} aria-hidden="true">
<span className="absolute -top-[7.5px] -left-[25px] i-custom-public-step-by-step-tour-coachmark-arrow h-[75.5px] w-[52px]" />
</div>
<section
aria-label={guide.title}
className={cn(
'relative flex min-h-[158px] w-full flex-col rounded-2xl border-[0.5px] border-state-base-hover-alt bg-[#e9f0ff] p-4 shadow-[0_20px_24px_-4px_var(--color-shadow-shadow-5),0_8px_8px_-4px_var(--color-shadow-shadow-1)] backdrop-blur-[5px]',
)}
>
<div className="pb-0.5 system-2xs-semibold-uppercase text-text-tertiary">{stepLabel}</div>
<h2 className="mt-1 system-md-medium text-text-primary">{guide.title}</h2>
<p className="mt-1 system-xs-regular text-text-secondary">{guide.description}</p>
<div className="mt-auto flex h-12 items-center justify-between gap-3 pt-4">
<Button variant="ghost" size="medium" className="px-0 text-text-tertiary hover:bg-transparent hover:text-text-secondary" onClick={onSkip}>
{skipLabel}
</Button>
<div className="flex shrink-0 items-center gap-1">
{learnMoreHref && (
<a
href={learnMoreHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 w-[117px] items-center justify-center gap-1 rounded-lg px-2 system-sm-medium text-text-tertiary outline-hidden hover:bg-components-button-ghost-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{guide.learnMoreLabel}
<span aria-hidden className="i-ri-arrow-right-up-line size-4" />
</a>
)}
<Button variant="primary" size="medium" className="w-20" onClick={onComplete}>
{guide.primaryActionLabel}
</Button>
</div>
</div>
</section>
</div>
</>
)
}

View File

@ -0,0 +1,54 @@
import type { StepByStepTourAccountState, StepByStepTourTaskDefinition } from './types'
import { buildIntegrationPath } from '@/app/components/integrations/routes'
import { STEP_BY_STEP_TOUR_TARGETS } from './target-registry'
export const STEP_BY_STEP_TOUR_STORAGE_KEY = 'step-by-step-tour-account-state'
export const STEP_BY_STEP_TOUR_TASKS = [
{
id: 'home',
route: '/',
target: STEP_BY_STEP_TOUR_TARGETS.home,
iconClassName: 'i-custom-vender-line-education-book-open-01',
fallbackTarget: STEP_BY_STEP_TOUR_TARGETS.home,
learnMoreDocPath: '/use-dify/getting-started/introduction',
canClickThrough: true,
},
{
id: 'studio',
route: '/apps',
target: STEP_BY_STEP_TOUR_TARGETS.studio,
iconClassName: 'i-custom-vender-main-nav-studio',
fallbackTarget: STEP_BY_STEP_TOUR_TARGETS.studio,
learnMoreDocPath: '/use-dify/workspace/app-management',
canClickThrough: true,
},
{
id: 'knowledge',
route: '/datasets',
target: STEP_BY_STEP_TOUR_TARGETS.knowledge,
iconClassName: 'i-custom-vender-main-nav-knowledge',
fallbackTarget: STEP_BY_STEP_TOUR_TARGETS.knowledge,
learnMoreDocPath: '/use-dify/knowledge/create-knowledge/introduction',
canClickThrough: true,
permissionFallback: 'show-disabled-reason',
},
{
id: 'integration',
route: buildIntegrationPath('provider'),
target: STEP_BY_STEP_TOUR_TARGETS.integration,
iconClassName: 'i-custom-vender-main-nav-integrations',
fallbackTarget: STEP_BY_STEP_TOUR_TARGETS.integration,
learnMoreDocPath: '/use-dify/workspace/plugins',
canClickThrough: true,
permissionFallback: 'show-disabled-reason',
},
] as const satisfies readonly StepByStepTourTaskDefinition[]
export const createDefaultStepByStepTourAccountState = (): StepByStepTourAccountState => ({
manuallyEnabledWorkspaceIds: [],
manuallyDisabledWorkspaceIds: [],
minimized: false,
completedTaskIds: [],
skipped: false,
})

View File

@ -0,0 +1,288 @@
'use client'
import type { StepByStepTourTaskId, StepByStepTourTaskView } from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
export type FloatingChecklistProps = {
className?: string
title: string
duration: string
minimized: boolean
progress: {
completed: number
total: number
}
tasks: StepByStepTourTaskView[]
skipLabel: string
minimizeLabel: string
restoreLabel: string
onMinimize: () => void
onRestore: () => void
onSkip: () => void
onCompleteTask: (taskId: StepByStepTourTaskId) => void
onStartTask: (taskId: StepByStepTourTaskId) => void
onUncompleteTask: (taskId: StepByStepTourTaskId) => void
}
export function FloatingChecklist({
className,
title,
duration,
minimized,
progress,
tasks,
skipLabel,
minimizeLabel,
restoreLabel,
onMinimize,
onRestore,
onSkip,
onCompleteTask,
onStartTask,
onUncompleteTask,
}: FloatingChecklistProps) {
if (minimized) {
return (
<MinimizedTourPill
title={title}
progress={progress}
restoreLabel={restoreLabel}
onRestore={onRestore}
className={className}
/>
)
}
return (
<section
aria-label={title}
className={cn(
'flex w-[320px] max-w-[calc(100vw-16px)] flex-col overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-[0_20px_24px_-4px_var(--color-shadow-shadow-5),0_8px_8px_-4px_var(--color-shadow-shadow-1)] backdrop-blur-[5px]',
className,
)}
>
<div className="flex w-full shrink-0 flex-col gap-2 px-4 pt-4 pb-1">
<div className="flex w-full items-start gap-1">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<h2 className="system-xl-semibold text-text-secondary">{title}</h2>
<p className="system-xs-regular text-text-tertiary">{duration}</p>
</div>
<Button variant="ghost" size="small" className="h-6 px-1.5 text-text-tertiary" onClick={onSkip}>
{skipLabel}
</Button>
<Button
variant="ghost"
size="small"
className="size-6 px-0 text-text-tertiary hover:text-text-secondary"
aria-label={minimizeLabel}
onClick={onMinimize}
>
<span aria-hidden className="i-ri-collapse-diagonal-2-line size-3.5" />
</Button>
</div>
<TourProgress completed={progress.completed} total={progress.total} />
</div>
<div className="flex w-full shrink-0 flex-col gap-1 p-2">
{tasks.map(task => (
<TourTaskRow
key={task.id}
task={task}
onCompleteTask={onCompleteTask}
onStartTask={onStartTask}
onUncompleteTask={onUncompleteTask}
/>
))}
</div>
</section>
)
}
function MinimizedTourPill({
title,
progress,
restoreLabel,
onRestore,
className,
}: {
title: string
progress: FloatingChecklistProps['progress']
restoreLabel: string
onRestore: () => void
className?: string
}) {
return (
<button
type="button"
aria-label={restoreLabel}
className={cn(
'inline-flex h-8 w-[183px] max-w-[calc(100vw-16px)] items-center gap-2 overflow-hidden rounded-full border-[0.5px] border-components-panel-border bg-background-section px-3 py-2 text-saas-dify-blue-inverted outline-hidden transition-colors hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
className,
)}
onClick={onRestore}
>
<span aria-hidden className="i-custom-vender-line-education-lesson-open-01 size-4 shrink-0" />
<span className="w-[104px] shrink-0 truncate system-sm-medium">{title}</span>
<span className="flex min-w-4 shrink-0 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary tabular-nums">
{`${progress.completed}/${progress.total}`}
</span>
</button>
)
}
function TourProgress({
completed,
total,
}: {
completed: number
total: number
}) {
return (
<>
<div
className="sr-only"
role="progressbar"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={completed}
aria-valuetext={`${completed} of ${total} steps completed`}
/>
<div className="flex w-full items-center gap-1 py-0.5" aria-hidden="true">
{Array.from({ length: total }, (_, index) => {
const active = index < completed
return (
<div
key={index}
className={cn(
'h-1 min-w-0 flex-1 rounded-full',
active ? 'bg-saas-dify-blue-inverted' : 'bg-components-slider-track',
)}
/>
)
})}
</div>
</>
)
}
function TourTaskRow({
task,
onCompleteTask,
onStartTask,
onUncompleteTask,
}: {
task: StepByStepTourTaskView
onCompleteTask: (taskId: StepByStepTourTaskId) => void
onStartTask: (taskId: StepByStepTourTaskId) => void
onUncompleteTask: (taskId: StepByStepTourTaskId) => void
}) {
const completed = task.status === 'completed'
const current = task.status === 'current'
const disabled = task.status === 'disabled'
return (
<div
aria-current={current ? 'step' : undefined}
aria-disabled={disabled || undefined}
className={cn(
'group flex w-full gap-1 rounded-xl p-2 transition-colors',
completed ? 'items-center' : 'items-start',
current && 'bg-state-base-hover-subtle',
disabled && 'cursor-not-allowed opacity-60',
)}
>
<div className={cn('flex min-w-0 flex-1 gap-3', completed ? 'items-center' : 'items-start')}>
<div
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-[10px] border border-components-panel-border-subtle bg-components-panel-bg text-text-accent-light-mode-only',
completed && 'opacity-50',
)}
>
<span aria-hidden className={cn('size-4', task.iconClassName)} />
</div>
<div className={cn('min-w-0 flex-1', completed && 'opacity-50')}>
<div className={cn('system-md-medium text-text-secondary', completed && 'line-through')}>
{task.title}
</div>
{!completed && (
<>
<p className="mt-0.5 system-xs-regular text-text-tertiary">
{disabled && task.disabledReason ? task.disabledReason : task.description}
</p>
<div className="mt-2 flex items-center gap-1 pb-1">
<Button
variant="secondary"
size="small"
className="min-w-[83px]"
disabled={disabled}
onClick={() => onStartTask(task.id)}
>
{task.primaryActionLabel}
</Button>
{task.learnMoreHref && task.learnMoreLabel && (
<a
href={task.learnMoreHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-6 min-w-[98px] items-center justify-center gap-1 rounded-md px-2 system-xs-medium text-text-tertiary outline-hidden hover:bg-components-button-ghost-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{task.learnMoreLabel}
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
</a>
)}
</div>
</>
)}
</div>
</div>
<TaskStatusIndicator
completed={completed}
disabled={disabled}
completeLabel={`Mark ${task.title} complete`}
incompleteLabel={`Mark ${task.title} incomplete`}
onComplete={() => onCompleteTask(task.id)}
onUncomplete={() => onUncompleteTask(task.id)}
/>
</div>
)
}
function TaskStatusIndicator({
completed,
disabled,
completeLabel,
incompleteLabel,
onComplete,
onUncomplete,
}: {
completed: boolean
disabled: boolean
completeLabel: string
incompleteLabel: string
onComplete: () => void
onUncomplete: () => void
}) {
if (completed) {
return (
<button
type="button"
aria-label={incompleteLabel}
className="flex size-[18px] shrink-0 items-center justify-center rounded-full bg-saas-dify-blue-accessible text-text-primary-on-surface outline-hidden hover:bg-saas-dify-blue-inverted focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={onUncomplete}
>
<span aria-hidden className="i-ri-check-line size-3" />
</button>
)
}
return (
<button
type="button"
aria-label={completeLabel}
disabled={disabled}
className="flex size-[18px] shrink-0 items-center justify-center rounded-full border border-components-checkbox-border bg-components-checkbox-bg-unchecked outline-hidden hover:border-state-accent-solid focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:hover:border-components-checkbox-border"
onClick={onComplete}
/>
)
}

View File

@ -0,0 +1,245 @@
'use client'
import type { StepByStepTourAccountState, StepByStepTourTaskId, StepByStepTourTaskView } from './types'
import { Popover, PopoverContent } from '@langgenius/dify-ui/popover'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useDocLink } from '@/context/i18n'
import { usePathname, useRouter } from '@/next/navigation'
import { StepByStepTourCoachmark } from './coachmark'
import { STEP_BY_STEP_TOUR_TASKS } from './constants'
import { FloatingChecklist } from './floating-widget'
import {
useSetStepByStepTourAccountState as useSetStepByStepTourAccount,
useStepByStepTourAccountStateValue as useStepByStepTourAccountValue,
} from './storage'
import { STEP_BY_STEP_TOUR_GUIDES } from './target-registry'
import { useStepByStepTourTarget } from './use-tour-target'
const addCompletedTask = (
completedTaskIds: StepByStepTourTaskId[],
taskId: StepByStepTourTaskId,
) => {
if (completedTaskIds.includes(taskId))
return completedTaskIds
return [...completedTaskIds, taskId]
}
const removeCompletedTask = (
completedTaskIds: StepByStepTourTaskId[],
taskId: StepByStepTourTaskId,
) => completedTaskIds.filter(id => id !== taskId)
const removeWorkspaceId = (workspaceIds: string[], workspaceId: string) =>
workspaceIds.filter(id => id !== workspaceId)
const getEnabledForCurrentWorkspace = (
accountState: StepByStepTourAccountState,
currentWorkspaceId: string,
) => !accountState.skipped
&& !accountState.manuallyDisabledWorkspaceIds.includes(currentWorkspaceId)
&& (
accountState.firstWorkspaceId === currentWorkspaceId
|| accountState.manuallyEnabledWorkspaceIds.includes(currentWorkspaceId)
)
const shouldHideOnPathname = (pathname: string) =>
pathname.startsWith('/app/') || pathname.includes('/installed/')
type StepByStepTourMountProps = {
className?: string
}
export default function StepByStepTourMount({
className,
}: StepByStepTourMountProps) {
const router = useRouter()
const pathname = usePathname()
const docLink = useDocLink()
const { t } = useTranslation('common')
const { currentWorkspace } = useAppContext()
const accountState = useStepByStepTourAccountValue()
const setAccountState = useSetStepByStepTourAccount()
const anchorRef = useRef<HTMLDivElement>(null)
const currentWorkspaceId = currentWorkspace.id
useEffect(() => {
if (accountState.firstWorkspaceId)
return
setAccountState({
...accountState,
firstWorkspaceId: currentWorkspaceId,
})
}, [accountState, currentWorkspaceId, setAccountState])
const enabledForCurrentWorkspace = getEnabledForCurrentWorkspace(accountState, currentWorkspaceId)
const completedTaskIds = accountState.completedTaskIds
const currentTask = STEP_BY_STEP_TOUR_TASKS.find(task => !completedTaskIds.includes(task.id))
const activeTask = accountState.activeTaskId
? STEP_BY_STEP_TOUR_TASKS.find(task => task.id === accountState.activeTaskId)
: undefined
const activeGuide = activeTask ? STEP_BY_STEP_TOUR_GUIDES[activeTask.id] : undefined
const hasActiveGuide = Boolean(activeTask && activeGuide)
const minimized = Boolean(activeTask) || accountState.minimized
const expanded = !minimized
const activeTaskIndex = activeTask
? STEP_BY_STEP_TOUR_TASKS.findIndex(task => task.id === activeTask.id)
: -1
const activeTaskLearnMoreHref = activeTask?.learnMoreDocPath
? docLink(activeTask.learnMoreDocPath)
: undefined
const visible = IS_CLOUD_EDITION
&& enabledForCurrentWorkspace
&& (hasActiveGuide || !shouldHideOnPathname(pathname))
const activeTargetElement = useStepByStepTourTarget(activeGuide?.target)
if (!visible)
return null
const title = t('stepByStepTour.title')
const learnMoreLabel = t('stepByStepTour.learnMore')
const taskCopy: Record<StepByStepTourTaskId, Pick<StepByStepTourTaskView, 'title' | 'description' | 'primaryActionLabel'>> = {
home: {
title: t('stepByStepTour.tasks.home.title'),
description: t('stepByStepTour.tasks.home.description'),
primaryActionLabel: t('stepByStepTour.tasks.home.primaryActionLabel'),
},
studio: {
title: t('stepByStepTour.tasks.studio.title'),
description: t('stepByStepTour.tasks.studio.description'),
primaryActionLabel: t('stepByStepTour.tasks.studio.primaryActionLabel'),
},
knowledge: {
title: t('stepByStepTour.tasks.knowledge.title'),
description: t('stepByStepTour.tasks.knowledge.description'),
primaryActionLabel: t('stepByStepTour.tasks.knowledge.primaryActionLabel'),
},
integration: {
title: t('stepByStepTour.tasks.integration.title'),
description: t('stepByStepTour.tasks.integration.description'),
primaryActionLabel: t('stepByStepTour.tasks.integration.primaryActionLabel'),
},
}
const tasks = STEP_BY_STEP_TOUR_TASKS.map((task): StepByStepTourTaskView => {
const completed = completedTaskIds.includes(task.id)
return {
...taskCopy[task.id],
id: task.id,
iconClassName: task.iconClassName,
status: completed
? 'completed'
: task.id === currentTask?.id ? 'current' : 'pending',
learnMoreLabel,
learnMoreHref: task.learnMoreDocPath ? docLink(task.learnMoreDocPath) : undefined,
}
})
const updateAccountState = (nextState: StepByStepTourAccountState) => {
setAccountState(nextState)
}
const completeActiveTask = () => {
if (!activeTask)
return
updateAccountState({
...accountState,
activeTaskId: undefined,
completedTaskIds: addCompletedTask(accountState.completedTaskIds, activeTask.id),
minimized: false,
})
}
const floatingChecklist = (
<FloatingChecklist
title={title}
duration={t('stepByStepTour.duration')}
minimized={minimized}
progress={{
completed: completedTaskIds.length,
total: STEP_BY_STEP_TOUR_TASKS.length,
}}
tasks={tasks}
skipLabel={t('stepByStepTour.skip')}
minimizeLabel={t('stepByStepTour.minimize')}
restoreLabel={t('stepByStepTour.restore')}
onMinimize={() => updateAccountState({ ...accountState, minimized: true })}
onRestore={() => updateAccountState({ ...accountState, minimized: false })}
onSkip={() => updateAccountState({
...accountState,
skipped: true,
manuallyEnabledWorkspaceIds: removeWorkspaceId(accountState.manuallyEnabledWorkspaceIds, currentWorkspaceId),
})}
onCompleteTask={(taskId) => {
updateAccountState({
...accountState,
completedTaskIds: addCompletedTask(accountState.completedTaskIds, taskId),
})
}}
onStartTask={(taskId) => {
const task = STEP_BY_STEP_TOUR_TASKS.find(item => item.id === taskId)
if (!task)
return
updateAccountState({
...accountState,
activeTaskId: taskId,
minimized: true,
})
router.push(task.route)
}}
onUncompleteTask={(taskId) => {
updateAccountState({
...accountState,
completedTaskIds: removeCompletedTask(accountState.completedTaskIds, taskId),
})
}}
/>
)
return (
<div className={className}>
{activeTask && activeGuide && activeTargetElement && (
<StepByStepTourCoachmark
key={activeGuide.target}
guide={activeGuide}
targetElement={activeTargetElement}
stepLabel={`${activeTaskIndex + 1} of ${STEP_BY_STEP_TOUR_TASKS.length}`}
skipLabel={t('stepByStepTour.skip')}
learnMoreHref={activeTaskLearnMoreHref}
onSkip={() => updateAccountState({
...accountState,
activeTaskId: undefined,
minimized: true,
})}
onComplete={completeActiveTask}
/>
)}
<Popover open={expanded}>
<div ref={anchorRef} aria-hidden="true" className="h-0 w-0" />
{minimized && floatingChecklist}
<PopoverContent
placement="top-start"
sideOffset={8}
positionerProps={{
anchor: anchorRef,
collisionPadding: 8,
collisionAvoidance: {
side: 'shift',
align: 'shift',
fallbackAxisSide: 'none',
},
}}
popupClassName="max-h-[calc(100vh-16px)] overflow-y-auto rounded-none border-0 bg-transparent p-0 shadow-none"
>
{floatingChecklist}
</PopoverContent>
</Popover>
</div>
)
}

View File

@ -0,0 +1,22 @@
'use client'
import type { StepByStepTourAccountState } from './types'
import { createLocalStorageState } from 'foxact/create-local-storage-state'
import {
createDefaultStepByStepTourAccountState,
STEP_BY_STEP_TOUR_STORAGE_KEY,
} from './constants'
const [
_useStepByStepTourAccountState,
useStepByStepTourAccountStateValue,
useSetStepByStepTourAccountState,
] = createLocalStorageState<StepByStepTourAccountState>(
STEP_BY_STEP_TOUR_STORAGE_KEY,
createDefaultStepByStepTourAccountState(),
)
export {
useSetStepByStepTourAccountState,
useStepByStepTourAccountStateValue,
}

View File

@ -0,0 +1,31 @@
import type { StepByStepTourTaskId } from './types'
export const STEP_BY_STEP_TOUR_TARGETS = {
home: 'step-by-step-tour-home',
studio: 'step-by-step-tour-studio',
knowledge: 'step-by-step-tour-knowledge',
integration: 'step-by-step-tour-integration',
} as const
export type StepByStepTourGuide = {
taskId: StepByStepTourTaskId
target: string
title: string
description: string
learnMoreLabel: string
primaryActionLabel: string
}
export const STEP_BY_STEP_TOUR_GUIDES: Partial<Record<StepByStepTourTaskId, StepByStepTourGuide>> = {
integration: {
taskId: 'integration',
target: STEP_BY_STEP_TOUR_TARGETS.integration,
title: 'Keep tools up to date',
description: 'Turn on Auto-update so installed tool plugins stay on the latest version automatically.',
learnMoreLabel: 'Learn more',
primaryActionLabel: 'Got it',
},
}
export const getStepByStepTourTargetSelector = (target: string) =>
`[data-step-by-step-tour-target="${target}"]`

View File

@ -0,0 +1,44 @@
import type { DocPathWithoutLang } from '@/types/doc-paths'
export const STEP_BY_STEP_TOUR_TASK_IDS = ['home', 'studio', 'knowledge', 'integration'] as const
export type StepByStepTourTaskId = typeof STEP_BY_STEP_TOUR_TASK_IDS[number]
export type StepByStepTourTaskStatus = 'completed' | 'current' | 'pending' | 'disabled'
export type StepByStepTourAccountState = {
firstWorkspaceId?: string
activeTaskId?: StepByStepTourTaskId
manuallyEnabledWorkspaceIds: string[]
manuallyDisabledWorkspaceIds: string[]
minimized: boolean
completedTaskIds: StepByStepTourTaskId[]
skipped: boolean
}
export type StepByStepTourPermissionFallback
= | 'show-parent-empty-state'
| 'show-disabled-reason'
export type StepByStepTourTaskDefinition = {
id: StepByStepTourTaskId
route: string
target: string
iconClassName: string
fallbackTarget?: string
learnMoreDocPath?: DocPathWithoutLang
canClickThrough: boolean
permissionFallback?: StepByStepTourPermissionFallback
}
export type StepByStepTourTaskView = {
id: StepByStepTourTaskId
title: string
description: string
iconClassName: string
status: StepByStepTourTaskStatus
primaryActionLabel: string
disabledReason?: string
learnMoreLabel?: string
learnMoreHref?: string
}

View File

@ -0,0 +1,85 @@
'use client'
import type { CSSProperties } from 'react'
import type { StepByStepTourTargetRect } from './use-target-rect'
import { useLayoutEffect, useState } from 'react'
const BUBBLE_WIDTH = 352
const BUBBLE_HEIGHT = 158
const BUBBLE_SIDE_OFFSET = 20
const VIEWPORT_PADDING = 8
const FIGMA_ARROW_FRAME_LEFT = 28
const FIGMA_ARROW_DOT_CENTER_X = 1
const MIN_ARROW_LEFT = 12
const MAX_ARROW_RIGHT_PADDING = 12
type ViewportSize = {
height: number
width: number
}
type CoachmarkPosition = {
arrowStyle: CSSProperties
bubbleStyle: CSSProperties
}
const getViewportSize = (): ViewportSize => ({
height: window.innerHeight,
width: window.innerWidth,
})
const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max)
}
export const getStepByStepTourCoachmarkPosition = (
targetRect: StepByStepTourTargetRect,
viewportSize: ViewportSize,
): CoachmarkPosition => {
const maxBubbleLeft = viewportSize.width - BUBBLE_WIDTH - VIEWPORT_PADDING
const bubbleLeft = clamp(
targetRect.left,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, maxBubbleLeft),
)
const maxBubbleTop = viewportSize.height - BUBBLE_HEIGHT - VIEWPORT_PADDING
const bubbleTop = clamp(
targetRect.top + targetRect.height + BUBBLE_SIDE_OFFSET,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, maxBubbleTop),
)
const targetAnchorX = targetRect.left + FIGMA_ARROW_FRAME_LEFT + FIGMA_ARROW_DOT_CENTER_X
const arrowLeft = clamp(
targetAnchorX - bubbleLeft - FIGMA_ARROW_DOT_CENTER_X,
MIN_ARROW_LEFT,
BUBBLE_WIDTH - MAX_ARROW_RIGHT_PADDING,
)
return {
arrowStyle: {
left: arrowLeft,
},
bubbleStyle: {
left: bubbleLeft,
top: bubbleTop,
},
}
}
export const useStepByStepTourCoachmarkPosition = (targetRect: StepByStepTourTargetRect) => {
const [viewportSize, setViewportSize] = useState<ViewportSize>(() => getViewportSize())
useLayoutEffect(() => {
const syncViewportSize = () => {
setViewportSize(getViewportSize())
}
window.addEventListener('resize', syncViewportSize)
return () => {
window.removeEventListener('resize', syncViewportSize)
}
}, [])
return getStepByStepTourCoachmarkPosition(targetRect, viewportSize)
}

View File

@ -0,0 +1,58 @@
'use client'
import { useLayoutEffect, useRef, useState } from 'react'
export type StepByStepTourTargetRect = {
height: number
left: number
top: number
width: number
}
const getTargetRect = (targetElement: HTMLElement): StepByStepTourTargetRect => {
const rect = targetElement.getBoundingClientRect()
return {
height: rect.height,
left: rect.left,
top: rect.top,
width: rect.width,
}
}
const areTargetRectsEqual = (
currentRect: StepByStepTourTargetRect,
nextRect: StepByStepTourTargetRect,
) => {
return currentRect.height === nextRect.height
&& currentRect.left === nextRect.left
&& currentRect.top === nextRect.top
&& currentRect.width === nextRect.width
}
export const useStepByStepTourTargetRect = (targetElement: HTMLElement) => {
const [targetRect, setTargetRect] = useState(() => getTargetRect(targetElement))
const targetRectRef = useRef(targetRect)
targetRectRef.current = targetRect
useLayoutEffect(() => {
let animationFrame = 0
const syncRect = () => {
const nextRect = getTargetRect(targetElement)
if (!areTargetRectsEqual(targetRectRef.current, nextRect)) {
targetRectRef.current = nextRect
setTargetRect(nextRect)
}
animationFrame = window.requestAnimationFrame(syncRect)
}
animationFrame = window.requestAnimationFrame(syncRect)
return () => {
window.cancelAnimationFrame(animationFrame)
}
}, [targetElement])
return targetRect
}

View File

@ -0,0 +1,38 @@
'use client'
import { useEffect, useState } from 'react'
import { getStepByStepTourTargetSelector } from './target-registry'
export const useStepByStepTourTarget = (target?: string) => {
const [targetElement, setTargetElement] = useState<HTMLElement | null>(() => {
if (!target || typeof document === 'undefined')
return null
return document.querySelector<HTMLElement>(getStepByStepTourTargetSelector(target))
})
useEffect(() => {
if (typeof document === 'undefined')
return
const selector = target ? getStepByStepTourTargetSelector(target) : undefined
const syncTarget = () => {
setTargetElement(selector ? document.querySelector<HTMLElement>(selector) : null)
}
const animationFrame = window.requestAnimationFrame(syncTarget)
const observer = new MutationObserver(syncTarget)
observer.observe(document.body, {
childList: true,
subtree: true,
})
return () => {
window.cancelAnimationFrame(animationFrame)
observer.disconnect()
}
}, [target])
return targetElement
}

View File

@ -198,6 +198,7 @@
"mainNav.help.docs": "Documentation",
"mainNav.help.learnDify": "Learn Dify",
"mainNav.help.openMenu": "Open help menu",
"mainNav.help.stepByStepTour": "Step-by-step Tour",
"mainNav.home": "Home",
"mainNav.integrations": "Integrations",
"mainNav.marketplace": "Marketplace",
@ -531,6 +532,24 @@
"settings.swaggerAPIAsTool": "Swagger API as Tool",
"settings.trigger": "Trigger",
"settings.workspace": "WORKSPACE",
"stepByStepTour.duration": "A quick tour — about 5 minutes",
"stepByStepTour.learnMore": "Learn more",
"stepByStepTour.minimize": "Minimize tour",
"stepByStepTour.restore": "Open step-by-step tour",
"stepByStepTour.skip": "Skip",
"stepByStepTour.tasks.home.description": "Open a hands-on lesson from Learn Dify to see Dify in action.",
"stepByStepTour.tasks.home.primaryActionLabel": "Show me",
"stepByStepTour.tasks.home.title": "Try a Learn Dify lesson",
"stepByStepTour.tasks.integration.description": "Models, tools, data sources & more — explore what you can connect.",
"stepByStepTour.tasks.integration.primaryActionLabel": "Take a look",
"stepByStepTour.tasks.integration.title": "Explore integrations",
"stepByStepTour.tasks.knowledge.description": "Build a knowledge base so your apps answer from your documents.",
"stepByStepTour.tasks.knowledge.primaryActionLabel": "Take a look",
"stepByStepTour.tasks.knowledge.title": "Add your own data",
"stepByStepTour.tasks.studio.description": "All your apps live in Studio — edit, organize, and publish them here.",
"stepByStepTour.tasks.studio.primaryActionLabel": "Take a look",
"stepByStepTour.tasks.studio.title": "Manage your apps in Studio",
"stepByStepTour.title": "Get to know Dify",
"swaggerAPIAsToolPage.description": "Import any API as a tool using OpenAPI/Swagger specs. Configure once and reuse it across your workflows.",
"tag.addNew": "Add new tag",
"tag.addTag": "Add tags",

View File

@ -198,6 +198,7 @@
"mainNav.help.docs": "ドキュメント",
"mainNav.help.learnDify": "Difyを学ぶ",
"mainNav.help.openMenu": "ヘルプメニューを開く",
"mainNav.help.stepByStepTour": "Step-by-step Tour",
"mainNav.home": "ホーム",
"mainNav.integrations": "連携",
"mainNav.marketplace": "マーケットプレイス",
@ -531,6 +532,24 @@
"settings.swaggerAPIAsTool": "Swagger API をツールとして利用",
"settings.trigger": "トリガー",
"settings.workspace": "ワークスペース",
"stepByStepTour.duration": "簡単なガイド — 約 5 分",
"stepByStepTour.learnMore": "詳細を見る",
"stepByStepTour.minimize": "最小化",
"stepByStepTour.restore": "Step-by-step Tour を開く",
"stepByStepTour.skip": "ガイドをスキップ",
"stepByStepTour.tasks.home.description": "Learn Dify のハンズオンレッスンを開いて、Dify を体験しましょう。",
"stepByStepTour.tasks.home.primaryActionLabel": "見せて",
"stepByStepTour.tasks.home.title": "Learn Dify レッスンを試す",
"stepByStepTour.tasks.integration.description": "モデル、ツール、データソースなど — 接続できるものを見てみましょう。",
"stepByStepTour.tasks.integration.primaryActionLabel": "見てみる",
"stepByStepTour.tasks.integration.title": "連携を見る",
"stepByStepTour.tasks.knowledge.description": "ナレッジベースを構築して、アプリが自分のドキュメントから回答できるようにします。",
"stepByStepTour.tasks.knowledge.primaryActionLabel": "見てみる",
"stepByStepTour.tasks.knowledge.title": "独自のデータを追加",
"stepByStepTour.tasks.studio.description": "すべてのアプリは Studio に集約されます — 編集、整理、公開がここで行えます。",
"stepByStepTour.tasks.studio.primaryActionLabel": "見てみる",
"stepByStepTour.tasks.studio.title": "Studio でアプリを管理",
"stepByStepTour.title": "Dify を知ろう",
"swaggerAPIAsToolPage.description": "OpenAPI/Swagger 仕様を使って任意の API をツールとして取り込めます。一度設定すれば、複数のワークフローで再利用できます。",
"tag.addNew": "新しいタグを追加",
"tag.addTag": "タグを追加",

View File

@ -198,6 +198,7 @@
"mainNav.help.docs": "文档",
"mainNav.help.learnDify": "了解 Dify",
"mainNav.help.openMenu": "打开帮助菜单",
"mainNav.help.stepByStepTour": "Step-by-step Tour",
"mainNav.home": "主页",
"mainNav.integrations": "集成",
"mainNav.marketplace": "Marketplace",
@ -531,6 +532,24 @@
"settings.swaggerAPIAsTool": "Swagger API 作为工具",
"settings.trigger": "触发器",
"settings.workspace": "工作空间",
"stepByStepTour.duration": "快速浏览 — 大约 5 分钟",
"stepByStepTour.learnMore": "了解更多",
"stepByStepTour.minimize": "最小化",
"stepByStepTour.restore": "打开 Step-by-step Tour",
"stepByStepTour.skip": "跳过引导",
"stepByStepTour.tasks.home.description": "打开一节 Learn Dify 实操课程,亲手感受 Dify。",
"stepByStepTour.tasks.home.primaryActionLabel": "带我看看",
"stepByStepTour.tasks.home.title": "尝试 Learn Dify 课程",
"stepByStepTour.tasks.integration.description": "模型、工具、数据源……看看你能连接什么。",
"stepByStepTour.tasks.integration.primaryActionLabel": "去看看",
"stepByStepTour.tasks.integration.title": "探索集成",
"stepByStepTour.tasks.knowledge.description": "创建知识库,让你的应用基于自己的文档回答问题。",
"stepByStepTour.tasks.knowledge.primaryActionLabel": "去看看",
"stepByStepTour.tasks.knowledge.title": "添加你自己的数据",
"stepByStepTour.tasks.studio.description": "你的所有应用都在 Studio 中 — 编辑、组织和发布。",
"stepByStepTour.tasks.studio.primaryActionLabel": "去看看",
"stepByStepTour.tasks.studio.title": "在 Studio 管理你的应用",
"stepByStepTour.title": "认识 Dify",
"swaggerAPIAsToolPage.description": "使用 OpenAPI/Swagger 规范将任意 API 导入为工具。一次配置,即可在所有工作流中复用。",
"tag.addNew": "创建新标签",
"tag.addTag": "添加标签",