mirror of
https://github.com/langgenius/dify.git
synced 2026-06-27 17:47:08 +08:00
Compare commits
6 Commits
deploy/saa
...
feat/step-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ea435e6bd | |||
| e2f27c365a | |||
| a53a54d272 | |||
| a246dc8b17 | |||
| bb921bcc45 | |||
| 4f4ac27de2 |
2
.github/workflows/style.yml
vendored
2
.github/workflows/style.yml
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 |
@ -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 |
@ -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,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-public",
|
||||
"name": "Dify Custom Public",
|
||||
"total": 145,
|
||||
"total": 146,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
@ -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>"
|
||||
},
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"name": "Dify Custom Vender",
|
||||
"total": 326,
|
||||
"total": 327,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 />}
|
||||
|
||||
@ -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'
|
||||
|
||||
268
web/app/components/step-by-step-tour/README.md
Normal file
268
web/app/components/step-by-step-tour/README.md
Normal 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?
|
||||
348
web/app/components/step-by-step-tour/__tests__/mount.spec.tsx
Normal file
348
web/app/components/step-by-step-tour/__tests__/mount.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
94
web/app/components/step-by-step-tour/coachmark.tsx
Normal file
94
web/app/components/step-by-step-tour/coachmark.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
54
web/app/components/step-by-step-tour/constants.ts
Normal file
54
web/app/components/step-by-step-tour/constants.ts
Normal 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,
|
||||
})
|
||||
288
web/app/components/step-by-step-tour/floating-widget.tsx
Normal file
288
web/app/components/step-by-step-tour/floating-widget.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
245
web/app/components/step-by-step-tour/mount.tsx
Normal file
245
web/app/components/step-by-step-tour/mount.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
web/app/components/step-by-step-tour/storage.ts
Normal file
22
web/app/components/step-by-step-tour/storage.ts
Normal 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,
|
||||
}
|
||||
31
web/app/components/step-by-step-tour/target-registry.ts
Normal file
31
web/app/components/step-by-step-tour/target-registry.ts
Normal 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}"]`
|
||||
44
web/app/components/step-by-step-tour/types.ts
Normal file
44
web/app/components/step-by-step-tour/types.ts
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
58
web/app/components/step-by-step-tour/use-target-rect.ts
Normal file
58
web/app/components/step-by-step-tour/use-target-rect.ts
Normal 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
|
||||
}
|
||||
38
web/app/components/step-by-step-tour/use-tour-target.ts
Normal file
38
web/app/components/step-by-step-tour/use-tour-target.ts
Normal 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
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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": "タグを追加",
|
||||
|
||||
@ -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": "添加标签",
|
||||
|
||||
Reference in New Issue
Block a user