Compare commits

..

13 Commits

Author SHA1 Message Date
JzoNg
4d3738d225 Merge branch 'main' into feat/evaluation-fe 2026-03-17 10:42:44 +08:00
yyh
f198f5b0ab fix: raise block selector overlay z-index (#33557) 2026-03-17 10:39:48 +08:00
KVOJJJin
49e0e1b939 fix(web): page crash in knowledge retrieval node caused by dataset selection and score threshold (#33553)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-17 10:35:07 +08:00
statxc
f886f11094 refactor(api): replace dict/Mapping with TypedDict in dataset models (#33550) 2026-03-17 10:33:29 +09:00
dependabot[bot]
fa82a0f708 chore(deps): bump authlib from 1.6.7 to 1.6.9 in /api (#33544)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 05:06:07 +09:00
Coding On Star
0a3275fbe8 chore: update coverage summary check in web tests workflow (#33533)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-03-16 23:09:33 +08:00
JzoNg
dd0dee739d Merge branch 'main' into jzh 2026-03-16 15:43:20 +08:00
zxhlyh
4d19914fcb Merge branch 'main' into feat/evaluation-fe 2026-03-16 10:47:37 +08:00
zxhlyh
887c7710e9 feat: evaluation 2026-03-16 10:46:33 +08:00
zxhlyh
7a722773c7 feat: snippet canvas 2026-03-13 17:45:04 +08:00
zxhlyh
a763aff58b feat: snippets list 2026-03-13 16:12:42 +08:00
zxhlyh
c1011f4e5c feat: add to snippet 2026-03-13 14:29:59 +08:00
zxhlyh
f7afa103a5 feat: select snippets 2026-03-13 13:43:29 +08:00
82 changed files with 5929 additions and 1359 deletions

View File

@@ -89,14 +89,24 @@ jobs:
- name: Merge reports
run: vp test --merge-reports --reporter=json --reporter=agent --coverage
- name: Check app/components diff coverage
- name: Report app/components baseline coverage
run: node ./scripts/report-components-coverage-baseline.mjs
- name: Report app/components test touch
env:
BASE_SHA: ${{ inputs.base_sha }}
DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }}
HEAD_SHA: ${{ inputs.head_sha }}
run: node ./scripts/report-components-test-touch.mjs
- name: Check app/components pure diff coverage
env:
BASE_SHA: ${{ inputs.base_sha }}
DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }}
HEAD_SHA: ${{ inputs.head_sha }}
run: node ./scripts/check-components-diff-coverage.mjs
- name: Coverage Summary
- name: Check Coverage Summary
if: always()
id: coverage-summary
run: |
@@ -105,313 +115,15 @@ jobs:
COVERAGE_FILE="coverage/coverage-final.json"
COVERAGE_SUMMARY_FILE="coverage/coverage-summary.json"
if [ ! -f "$COVERAGE_FILE" ] && [ ! -f "$COVERAGE_SUMMARY_FILE" ]; then
echo "has_coverage=false" >> "$GITHUB_OUTPUT"
echo "### 🚨 Test Coverage Report :test_tube:" >> "$GITHUB_STEP_SUMMARY"
echo "Coverage data not found. Ensure Vitest runs with coverage enabled." >> "$GITHUB_STEP_SUMMARY"
if [ -f "$COVERAGE_FILE" ] || [ -f "$COVERAGE_SUMMARY_FILE" ]; then
echo "has_coverage=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "has_coverage=true" >> "$GITHUB_OUTPUT"
node <<'NODE' >> "$GITHUB_STEP_SUMMARY"
const fs = require('fs');
const path = require('path');
let libCoverage = null;
try {
libCoverage = require('istanbul-lib-coverage');
} catch (error) {
libCoverage = null;
}
const summaryPath = path.join('coverage', 'coverage-summary.json');
const finalPath = path.join('coverage', 'coverage-final.json');
const hasSummary = fs.existsSync(summaryPath);
const hasFinal = fs.existsSync(finalPath);
if (!hasSummary && !hasFinal) {
console.log('### Test Coverage Summary :test_tube:');
console.log('');
console.log('No coverage data found.');
process.exit(0);
}
const summary = hasSummary
? JSON.parse(fs.readFileSync(summaryPath, 'utf8'))
: null;
const coverage = hasFinal
? JSON.parse(fs.readFileSync(finalPath, 'utf8'))
: null;
const getLineCoverageFromStatements = (statementMap, statementHits) => {
const lineHits = {};
if (!statementMap || !statementHits) {
return lineHits;
}
Object.entries(statementMap).forEach(([key, statement]) => {
const line = statement?.start?.line;
if (!line) {
return;
}
const hits = statementHits[key] ?? 0;
const previous = lineHits[line];
lineHits[line] = previous === undefined ? hits : Math.max(previous, hits);
});
return lineHits;
};
const getFileCoverage = (entry) => (
libCoverage ? libCoverage.createFileCoverage(entry) : null
);
const getLineHits = (entry, fileCoverage) => {
const lineHits = entry.l ?? {};
if (Object.keys(lineHits).length > 0) {
return lineHits;
}
if (fileCoverage) {
return fileCoverage.getLineCoverage();
}
return getLineCoverageFromStatements(entry.statementMap ?? {}, entry.s ?? {});
};
const getUncoveredLines = (entry, fileCoverage, lineHits) => {
if (lineHits && Object.keys(lineHits).length > 0) {
return Object.entries(lineHits)
.filter(([, count]) => count === 0)
.map(([line]) => Number(line))
.sort((a, b) => a - b);
}
if (fileCoverage) {
return fileCoverage.getUncoveredLines();
}
return [];
};
const totals = {
lines: { covered: 0, total: 0 },
statements: { covered: 0, total: 0 },
branches: { covered: 0, total: 0 },
functions: { covered: 0, total: 0 },
};
const fileSummaries = [];
if (summary) {
const totalEntry = summary.total ?? {};
['lines', 'statements', 'branches', 'functions'].forEach((key) => {
if (totalEntry[key]) {
totals[key].covered = totalEntry[key].covered ?? 0;
totals[key].total = totalEntry[key].total ?? 0;
}
});
Object.entries(summary)
.filter(([file]) => file !== 'total')
.forEach(([file, data]) => {
fileSummaries.push({
file,
pct: data.lines?.pct ?? data.statements?.pct ?? 0,
lines: {
covered: data.lines?.covered ?? 0,
total: data.lines?.total ?? 0,
},
});
});
} else if (coverage) {
Object.entries(coverage).forEach(([file, entry]) => {
const fileCoverage = getFileCoverage(entry);
const lineHits = getLineHits(entry, fileCoverage);
const statementHits = entry.s ?? {};
const branchHits = entry.b ?? {};
const functionHits = entry.f ?? {};
const lineTotal = Object.keys(lineHits).length;
const lineCovered = Object.values(lineHits).filter((n) => n > 0).length;
const statementTotal = Object.keys(statementHits).length;
const statementCovered = Object.values(statementHits).filter((n) => n > 0).length;
const branchTotal = Object.values(branchHits).reduce((acc, branches) => acc + branches.length, 0);
const branchCovered = Object.values(branchHits).reduce(
(acc, branches) => acc + branches.filter((n) => n > 0).length,
0,
);
const functionTotal = Object.keys(functionHits).length;
const functionCovered = Object.values(functionHits).filter((n) => n > 0).length;
totals.lines.total += lineTotal;
totals.lines.covered += lineCovered;
totals.statements.total += statementTotal;
totals.statements.covered += statementCovered;
totals.branches.total += branchTotal;
totals.branches.covered += branchCovered;
totals.functions.total += functionTotal;
totals.functions.covered += functionCovered;
const pct = (covered, tot) => (tot > 0 ? (covered / tot) * 100 : 0);
fileSummaries.push({
file,
pct: pct(lineCovered || statementCovered, lineTotal || statementTotal),
lines: {
covered: lineCovered || statementCovered,
total: lineTotal || statementTotal,
},
});
});
}
const pct = (covered, tot) => (tot > 0 ? ((covered / tot) * 100).toFixed(2) : '0.00');
console.log('### Test Coverage Summary :test_tube:');
console.log('');
console.log('| Metric | Coverage | Covered / Total |');
console.log('|--------|----------|-----------------|');
console.log(`| Lines | ${pct(totals.lines.covered, totals.lines.total)}% | ${totals.lines.covered} / ${totals.lines.total} |`);
console.log(`| Statements | ${pct(totals.statements.covered, totals.statements.total)}% | ${totals.statements.covered} / ${totals.statements.total} |`);
console.log(`| Branches | ${pct(totals.branches.covered, totals.branches.total)}% | ${totals.branches.covered} / ${totals.branches.total} |`);
console.log(`| Functions | ${pct(totals.functions.covered, totals.functions.total)}% | ${totals.functions.covered} / ${totals.functions.total} |`);
console.log('');
console.log('<details><summary>File coverage (lowest lines first)</summary>');
console.log('');
console.log('```');
fileSummaries
.sort((a, b) => (a.pct - b.pct) || (b.lines.total - a.lines.total))
.slice(0, 25)
.forEach(({ file, pct, lines }) => {
console.log(`${pct.toFixed(2)}%\t${lines.covered}/${lines.total}\t${file}`);
});
console.log('```');
console.log('</details>');
if (coverage) {
const pctValue = (covered, tot) => {
if (tot === 0) {
return '0';
}
return ((covered / tot) * 100)
.toFixed(2)
.replace(/\.?0+$/, '');
};
const formatLineRanges = (lines) => {
if (lines.length === 0) {
return '';
}
const ranges = [];
let start = lines[0];
let end = lines[0];
for (let i = 1; i < lines.length; i += 1) {
const current = lines[i];
if (current === end + 1) {
end = current;
continue;
}
ranges.push(start === end ? `${start}` : `${start}-${end}`);
start = current;
end = current;
}
ranges.push(start === end ? `${start}` : `${start}-${end}`);
return ranges.join(',');
};
const tableTotals = {
statements: { covered: 0, total: 0 },
branches: { covered: 0, total: 0 },
functions: { covered: 0, total: 0 },
lines: { covered: 0, total: 0 },
};
const tableRows = Object.entries(coverage)
.map(([file, entry]) => {
const fileCoverage = getFileCoverage(entry);
const lineHits = getLineHits(entry, fileCoverage);
const statementHits = entry.s ?? {};
const branchHits = entry.b ?? {};
const functionHits = entry.f ?? {};
const lineTotal = Object.keys(lineHits).length;
const lineCovered = Object.values(lineHits).filter((n) => n > 0).length;
const statementTotal = Object.keys(statementHits).length;
const statementCovered = Object.values(statementHits).filter((n) => n > 0).length;
const branchTotal = Object.values(branchHits).reduce((acc, branches) => acc + branches.length, 0);
const branchCovered = Object.values(branchHits).reduce(
(acc, branches) => acc + branches.filter((n) => n > 0).length,
0,
);
const functionTotal = Object.keys(functionHits).length;
const functionCovered = Object.values(functionHits).filter((n) => n > 0).length;
tableTotals.lines.total += lineTotal;
tableTotals.lines.covered += lineCovered;
tableTotals.statements.total += statementTotal;
tableTotals.statements.covered += statementCovered;
tableTotals.branches.total += branchTotal;
tableTotals.branches.covered += branchCovered;
tableTotals.functions.total += functionTotal;
tableTotals.functions.covered += functionCovered;
const uncoveredLines = getUncoveredLines(entry, fileCoverage, lineHits);
const filePath = entry.path ?? file;
const relativePath = path.isAbsolute(filePath)
? path.relative(process.cwd(), filePath)
: filePath;
return {
file: relativePath || file,
statements: pctValue(statementCovered, statementTotal),
branches: pctValue(branchCovered, branchTotal),
functions: pctValue(functionCovered, functionTotal),
lines: pctValue(lineCovered, lineTotal),
uncovered: formatLineRanges(uncoveredLines),
};
})
.sort((a, b) => a.file.localeCompare(b.file));
const columns = [
{ key: 'file', header: 'File', align: 'left' },
{ key: 'statements', header: '% Stmts', align: 'right' },
{ key: 'branches', header: '% Branch', align: 'right' },
{ key: 'functions', header: '% Funcs', align: 'right' },
{ key: 'lines', header: '% Lines', align: 'right' },
{ key: 'uncovered', header: 'Uncovered Line #s', align: 'left' },
];
const allFilesRow = {
file: 'All files',
statements: pctValue(tableTotals.statements.covered, tableTotals.statements.total),
branches: pctValue(tableTotals.branches.covered, tableTotals.branches.total),
functions: pctValue(tableTotals.functions.covered, tableTotals.functions.total),
lines: pctValue(tableTotals.lines.covered, tableTotals.lines.total),
uncovered: '',
};
const rowsForOutput = [allFilesRow, ...tableRows];
const formatRow = (row) => `| ${columns
.map(({ key }) => String(row[key] ?? ''))
.join(' | ')} |`;
const headerRow = `| ${columns.map(({ header }) => header).join(' | ')} |`;
const dividerRow = `| ${columns
.map(({ align }) => (align === 'right' ? '---:' : ':---'))
.join(' | ')} |`;
console.log('');
console.log('<details><summary>Vitest coverage table</summary>');
console.log('');
console.log(headerRow);
console.log(dividerRow);
rowsForOutput.forEach((row) => console.log(formatRow(row)));
console.log('</details>');
}
NODE
echo "has_coverage=false" >> "$GITHUB_OUTPUT"
echo "### 🚨 app/components Diff Coverage" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Coverage artifacts not found. Ensure Vitest merge reports ran with coverage enabled." >> "$GITHUB_STEP_SUMMARY"
- name: Upload Coverage Artifact
if: steps.coverage-summary.outputs.has_coverage == 'true'

View File

@@ -4,8 +4,6 @@ from core.helper.code_executor.template_transformer import TemplateTransformer
class NodeJsTemplateTransformer(TemplateTransformer):
_comment_prefix: str = "//"
@classmethod
def get_runner_script(cls) -> str:
runner_script = dedent(f""" {cls._code_placeholder}

View File

@@ -31,7 +31,7 @@ class Jinja2TemplateTransformer(TemplateTransformer):
script = script.replace(cls._template_b64_placeholder, code_b64)
inputs_str = cls.serialize_inputs(inputs)
script = script.replace(cls._inputs_placeholder, inputs_str)
return cls._generate_anti_kpa_padding() + script
return script
@classmethod
def get_runner_script(cls) -> str:

View File

@@ -1,6 +1,5 @@
import json
import re
import secrets
from abc import ABC, abstractmethod
from base64 import b64encode
from collections.abc import Mapping
@@ -8,16 +7,11 @@ from typing import Any
from dify_graph.variables.utils import dumps_with_segments
# Minimum random padding bytes to exceed the sandbox XOR key length (64 bytes).
_ANTI_KPA_MIN_PADDING = 512
_ANTI_KPA_JITTER = 256
class TemplateTransformer(ABC):
_code_placeholder: str = "{{code}}"
_inputs_placeholder: str = "{{inputs}}"
_result_tag: str = "<<RESULT>>"
_comment_prefix: str = "#"
@classmethod
def serialize_code(cls, code: str) -> str:
@@ -112,12 +106,6 @@ class TemplateTransformer(ABC):
input_base64_encoded = b64encode(inputs_json_str).decode("utf-8")
return input_base64_encoded
@classmethod
def _generate_anti_kpa_padding(cls) -> str:
padding_size = secrets.randbelow(_ANTI_KPA_JITTER) + _ANTI_KPA_MIN_PADDING
nonce = secrets.token_hex(padding_size)
return f"{cls._comment_prefix} {nonce}\n"
@classmethod
def assemble_runner_script(cls, code: str, inputs: Mapping[str, Any]) -> str:
# assemble runner script
@@ -125,7 +113,7 @@ class TemplateTransformer(ABC):
script = script.replace(cls._code_placeholder, code)
inputs_str = cls.serialize_inputs(inputs)
script = script.replace(cls._inputs_placeholder, inputs_str)
return cls._generate_anti_kpa_padding() + script
return script
@classmethod
def get_preload_script(cls) -> str:

View File

@@ -5,6 +5,7 @@ import re
import threading
import time
import uuid
from collections.abc import Mapping
from typing import Any
from flask import Flask, current_app
@@ -37,7 +38,7 @@ from extensions.ext_storage import storage
from libs import helper
from libs.datetime_utils import naive_utc_now
from models import Account
from models.dataset import ChildChunk, Dataset, DatasetProcessRule, DocumentSegment
from models.dataset import AutomaticRulesConfig, ChildChunk, Dataset, DatasetProcessRule, DocumentSegment
from models.dataset import Document as DatasetDocument
from models.model import UploadFile
from services.feature_service import FeatureService
@@ -265,7 +266,7 @@ class IndexingRunner:
self,
tenant_id: str,
extract_settings: list[ExtractSetting],
tmp_processing_rule: dict,
tmp_processing_rule: Mapping[str, Any],
doc_form: str | None = None,
doc_language: str = "English",
dataset_id: str | None = None,
@@ -376,7 +377,7 @@ class IndexingRunner:
return IndexingEstimate(total_segments=total_segments, preview=preview_texts)
def _extract(
self, index_processor: BaseIndexProcessor, dataset_document: DatasetDocument, process_rule: dict
self, index_processor: BaseIndexProcessor, dataset_document: DatasetDocument, process_rule: Mapping[str, Any]
) -> list[Document]:
data_source_info = dataset_document.data_source_info_dict
text_docs = []
@@ -543,6 +544,7 @@ class IndexingRunner:
"""
Clean the document text according to the processing rules.
"""
rules: AutomaticRulesConfig | dict[str, Any]
if processing_rule.mode == "automatic":
rules = DatasetProcessRule.AUTOMATIC_RULES
else:
@@ -756,7 +758,7 @@ class IndexingRunner:
dataset: Dataset,
text_docs: list[Document],
doc_language: str,
process_rule: dict,
process_rule: Mapping[str, Any],
current_user: Account | None = None,
) -> list[Document]:
# get embedding model instance

View File

@@ -10,7 +10,7 @@ import re
import time
from datetime import datetime
from json import JSONDecodeError
from typing import Any, cast
from typing import Any, TypedDict, cast
from uuid import uuid4
import sqlalchemy as sa
@@ -37,6 +37,61 @@ from .types import AdjustedJSON, BinaryData, EnumText, LongText, StringUUID, adj
logger = logging.getLogger(__name__)
class PreProcessingRuleItem(TypedDict):
id: str
enabled: bool
class SegmentationConfig(TypedDict):
delimiter: str
max_tokens: int
chunk_overlap: int
class AutomaticRulesConfig(TypedDict):
pre_processing_rules: list[PreProcessingRuleItem]
segmentation: SegmentationConfig
class ProcessRuleDict(TypedDict):
id: str
dataset_id: str
mode: str
rules: dict[str, Any] | None
class DocMetadataDetailItem(TypedDict):
id: str
name: str
type: str
value: Any
class AttachmentItem(TypedDict):
id: str
name: str
size: int
extension: str
mime_type: str
source_url: str
class DatasetBindingItem(TypedDict):
id: str
name: str
class ExternalKnowledgeApiDict(TypedDict):
id: str
tenant_id: str
name: str
description: str
settings: dict[str, Any] | None
dataset_bindings: list[DatasetBindingItem]
created_by: str
created_at: str
class DatasetPermissionEnum(enum.StrEnum):
ONLY_ME = "only_me"
ALL_TEAM = "all_team_members"
@@ -334,7 +389,7 @@ class DatasetProcessRule(Base): # bug
MODES = ["automatic", "custom", "hierarchical"]
PRE_PROCESSING_RULES = ["remove_stopwords", "remove_extra_spaces", "remove_urls_emails"]
AUTOMATIC_RULES: dict[str, Any] = {
AUTOMATIC_RULES: AutomaticRulesConfig = {
"pre_processing_rules": [
{"id": "remove_extra_spaces", "enabled": True},
{"id": "remove_urls_emails", "enabled": False},
@@ -342,7 +397,7 @@ class DatasetProcessRule(Base): # bug
"segmentation": {"delimiter": "\n", "max_tokens": 500, "chunk_overlap": 50},
}
def to_dict(self) -> dict[str, Any]:
def to_dict(self) -> ProcessRuleDict:
return {
"id": self.id,
"dataset_id": self.dataset_id,
@@ -531,7 +586,7 @@ class Document(Base):
return self.updated_at
@property
def doc_metadata_details(self) -> list[dict[str, Any]] | None:
def doc_metadata_details(self) -> list[DocMetadataDetailItem] | None:
if self.doc_metadata:
document_metadatas = (
db.session.query(DatasetMetadata)
@@ -541,9 +596,9 @@ class Document(Base):
)
.all()
)
metadata_list: list[dict[str, Any]] = []
metadata_list: list[DocMetadataDetailItem] = []
for metadata in document_metadatas:
metadata_dict: dict[str, Any] = {
metadata_dict: DocMetadataDetailItem = {
"id": metadata.id,
"name": metadata.name,
"type": metadata.type,
@@ -557,13 +612,13 @@ class Document(Base):
return None
@property
def process_rule_dict(self) -> dict[str, Any] | None:
def process_rule_dict(self) -> ProcessRuleDict | None:
if self.dataset_process_rule_id and self.dataset_process_rule:
return self.dataset_process_rule.to_dict()
return None
def get_built_in_fields(self) -> list[dict[str, Any]]:
built_in_fields: list[dict[str, Any]] = []
def get_built_in_fields(self) -> list[DocMetadataDetailItem]:
built_in_fields: list[DocMetadataDetailItem] = []
built_in_fields.append(
{
"id": "built-in",
@@ -877,7 +932,7 @@ class DocumentSegment(Base):
return text
@property
def attachments(self) -> list[dict[str, Any]]:
def attachments(self) -> list[AttachmentItem]:
# Use JOIN to fetch attachments in a single query instead of two separate queries
attachments_with_bindings = db.session.execute(
select(SegmentAttachmentBinding, UploadFile)
@@ -891,7 +946,7 @@ class DocumentSegment(Base):
).all()
if not attachments_with_bindings:
return []
attachment_list = []
attachment_list: list[AttachmentItem] = []
for _, attachment in attachments_with_bindings:
upload_file_id = attachment.id
nonce = os.urandom(16).hex()
@@ -1261,7 +1316,7 @@ class ExternalKnowledgeApis(TypeBase):
DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), init=False
)
def to_dict(self) -> dict[str, Any]:
def to_dict(self) -> ExternalKnowledgeApiDict:
return {
"id": self.id,
"tenant_id": self.tenant_id,
@@ -1281,13 +1336,13 @@ class ExternalKnowledgeApis(TypeBase):
return None
@property
def dataset_bindings(self) -> list[dict[str, Any]]:
def dataset_bindings(self) -> list[DatasetBindingItem]:
external_knowledge_bindings = db.session.scalars(
select(ExternalKnowledgeBindings).where(ExternalKnowledgeBindings.external_knowledge_api_id == self.id)
).all()
dataset_ids = [binding.dataset_id for binding in external_knowledge_bindings]
datasets = db.session.scalars(select(Dataset).where(Dataset.id.in_(dataset_ids))).all()
dataset_bindings: list[dict[str, Any]] = []
dataset_bindings: list[DatasetBindingItem] = []
for dataset in datasets:
dataset_bindings.append({"id": dataset.id, "name": dataset.name})

View File

@@ -156,7 +156,8 @@ class VectorService:
)
# use full doc mode to generate segment's child chunk
processing_rule_dict = processing_rule.to_dict()
processing_rule_dict["rules"]["parent_mode"] = ParentMode.FULL_DOC
if processing_rule_dict["rules"] is not None:
processing_rule_dict["rules"]["parent_mode"] = ParentMode.FULL_DOC
documents = index_processor.transform(
[document],
embedding_model_instance=embedding_model_instance,

View File

@@ -8,17 +8,5 @@ def test_get_runner_script():
script = NodeJsTemplateTransformer.assemble_runner_script(code, inputs)
script_lines = script.splitlines()
code_lines = code.splitlines()
# First line is a random anti-KPA padding comment using JS syntax
assert script_lines[0].startswith("// ")
# User code follows immediately after the padding line
assert script_lines[1 : 1 + len(code_lines)] == code_lines
def test_anti_kpa_padding_is_unique():
code = JavascriptCodeProvider.get_default_code()
inputs = {"arg1": "a", "arg2": "b"}
script_a = NodeJsTemplateTransformer.assemble_runner_script(code, inputs)
script_b = NodeJsTemplateTransformer.assemble_runner_script(code, inputs)
padding_a = script_a.splitlines()[0]
padding_b = script_b.splitlines()[0]
assert padding_a != padding_b, "Each assembled script must have unique random padding"
# Check that the first lines of script are exactly the same as code
assert script_lines[: len(code_lines)] == code_lines

View File

@@ -8,17 +8,5 @@ def test_get_runner_script():
script = Python3TemplateTransformer.assemble_runner_script(code, inputs)
script_lines = script.splitlines()
code_lines = code.splitlines()
# First line is a random anti-KPA padding comment
assert script_lines[0].startswith("# ")
# User code follows immediately after the padding line
assert script_lines[1 : 1 + len(code_lines)] == code_lines
def test_anti_kpa_padding_is_unique():
code = Python3CodeProvider.get_default_code()
inputs = {"arg1": "a", "arg2": "b"}
script_a = Python3TemplateTransformer.assemble_runner_script(code, inputs)
script_b = Python3TemplateTransformer.assemble_runner_script(code, inputs)
padding_a = script_a.splitlines()[0]
padding_b = script_b.splitlines()[0]
assert padding_a != padding_b, "Each assembled script must have unique random padding"
# Check that the first lines of script are exactly the same as code
assert script_lines[: len(code_lines)] == code_lines

6
api/uv.lock generated
View File

@@ -457,14 +457,14 @@ wheels = [
[[package]]
name = "authlib"
version = "1.6.7"
version = "1.6.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/dc/ed1681bf1339dd6ea1ce56136bad4baabc6f7ad466e375810702b0237047/authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b", size = 164950, upload-time = "2026-02-06T14:04:14.171Z" }
sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/00/3ed12264094ec91f534fae429945efbaa9f8c666f3aa7061cc3b2a26a0cd/authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0", size = 244115, upload-time = "2026-02-06T14:04:12.141Z" },
{ url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" },
]
[[package]]

View File

@@ -163,9 +163,38 @@ describe('check-components-diff-coverage helpers', () => {
expect(coverage).toEqual({
covered: 0,
total: 2,
total: 1,
uncoveredBranches: [
{ armIndex: 0, line: 33 },
],
})
})
it('should require all branch arms when the branch condition changes', () => {
const entry = {
b: {
0: [0, 0],
},
branchMap: {
0: {
line: 30,
loc: { start: { line: 30 }, end: { line: 35 } },
locations: [
{ start: { line: 31 }, end: { line: 34 } },
{ start: { line: 35 }, end: { line: 38 } },
],
type: 'if',
},
},
}
const coverage = getChangedBranchCoverage(entry, new Set([30]))
expect(coverage).toEqual({
covered: 0,
total: 2,
uncoveredBranches: [
{ armIndex: 0, line: 31 },
{ armIndex: 1, line: 35 },
],
})

View File

@@ -0,0 +1,72 @@
import {
getCoverageStats,
isRelevantTestFile,
isTrackedComponentSourceFile,
loadTrackedCoverageEntries,
} from '../scripts/components-coverage-common.mjs'
describe('components coverage common helpers', () => {
it('should identify tracked component source files and relevant tests', () => {
const excludedComponentCoverageFiles = new Set([
'web/app/components/share/types.ts',
])
expect(isTrackedComponentSourceFile('web/app/components/share/index.tsx', excludedComponentCoverageFiles)).toBe(true)
expect(isTrackedComponentSourceFile('web/app/components/share/types.ts', excludedComponentCoverageFiles)).toBe(false)
expect(isTrackedComponentSourceFile('web/app/components/provider/index.tsx', excludedComponentCoverageFiles)).toBe(false)
expect(isRelevantTestFile('web/__tests__/share/text-generation-run-once-flow.test.tsx')).toBe(true)
expect(isRelevantTestFile('web/app/components/share/__tests__/index.spec.tsx')).toBe(true)
expect(isRelevantTestFile('web/utils/format.spec.ts')).toBe(false)
})
it('should load only tracked coverage entries from mixed coverage paths', () => {
const context = {
excludedComponentCoverageFiles: new Set([
'web/app/components/share/types.ts',
]),
repoRoot: '/repo',
webRoot: '/repo/web',
}
const coverage = {
'/repo/web/app/components/provider/index.tsx': {
path: '/repo/web/app/components/provider/index.tsx',
statementMap: { 0: { start: { line: 1 }, end: { line: 1 } } },
s: { 0: 1 },
},
'app/components/share/index.tsx': {
path: 'app/components/share/index.tsx',
statementMap: { 0: { start: { line: 2 }, end: { line: 2 } } },
s: { 0: 1 },
},
'app/components/share/types.ts': {
path: 'app/components/share/types.ts',
statementMap: { 0: { start: { line: 3 }, end: { line: 3 } } },
s: { 0: 1 },
},
}
expect([...loadTrackedCoverageEntries(coverage, context).keys()]).toEqual([
'web/app/components/share/index.tsx',
])
})
it('should calculate coverage stats using statement-derived line hits', () => {
const entry = {
b: { 0: [1, 0] },
f: { 0: 1, 1: 0 },
s: { 0: 1, 1: 0 },
statementMap: {
0: { start: { line: 10 }, end: { line: 10 } },
1: { start: { line: 12 }, end: { line: 13 } },
},
}
expect(getCoverageStats(entry)).toEqual({
branches: { covered: 1, total: 2 },
functions: { covered: 1, total: 2 },
lines: { covered: 1, total: 2 },
statements: { covered: 1, total: 2 },
})
})
})

View File

@@ -0,0 +1,11 @@
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ appId: string }>
}) => {
const { appId } = await props.params
return <Evaluation resourceType="workflow" resourceId={appId} />
}
export default Page

View File

@@ -7,6 +7,8 @@ import {
RiDashboard2Line,
RiFileList3Fill,
RiFileList3Line,
RiFlaskFill,
RiFlaskLine,
RiTerminalBoxFill,
RiTerminalBoxLine,
RiTerminalWindowFill,
@@ -67,40 +69,47 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
}>>([])
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
const navConfig = [
...(isCurrentWorkspaceEditor
? [{
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
}]
: []
),
{
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
},
...(isCurrentWorkspaceEditor
? [{
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
}]
: []
),
{
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
},
]
const navConfig = []
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
})
navConfig.push({
name: t('appMenus.evaluation', { ns: 'common' }),
href: `/app/${appId}/evaluation`,
icon: RiFlaskLine,
selectedIcon: RiFlaskFill,
})
}
navConfig.push({
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
})
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
})
}
navConfig.push({
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
})
return navConfig
}, [t])

View File

@@ -0,0 +1,11 @@
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ datasetId: string }>
}) => {
const { datasetId } = await props.params
return <Evaluation resourceType="pipeline" resourceId={datasetId} />
}
export default Page

View File

@@ -6,6 +6,8 @@ import {
RiEqualizer2Line,
RiFileTextFill,
RiFileTextLine,
RiFlaskFill,
RiFlaskLine,
RiFocus2Fill,
RiFocus2Line,
} from '@remixicon/react'
@@ -86,20 +88,30 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
]
if (datasetRes?.provider !== 'external') {
baseNavigation.unshift({
name: t('datasetMenus.pipeline', { ns: 'common' }),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
})
baseNavigation.unshift({
name: t('datasetMenus.documents', { ns: 'common' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
})
return [
{
name: t('datasetMenus.documents', { ns: 'common' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
},
{
name: t('datasetMenus.pipeline', { ns: 'common' }),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
},
{
name: t('datasetMenus.evaluation', { ns: 'common' }),
href: `/datasets/${datasetId}/evaluation`,
icon: RiFlaskLine,
selectedIcon: RiFlaskFill,
disabled: false,
},
...baseNavigation,
]
}
return baseNavigation

View File

@@ -6,7 +6,7 @@ import { useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)

View File

@@ -0,0 +1,11 @@
import SnippetPage from '@/app/components/snippets'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetPage snippetId={snippetId} section="evaluation" />
}
export default Page

View File

@@ -0,0 +1,11 @@
import SnippetPage from '@/app/components/snippets'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetPage snippetId={snippetId} section="orchestrate" />
}
export default Page

View File

@@ -0,0 +1,21 @@
import Page from './page'
const mockRedirect = vi.fn()
vi.mock('next/navigation', () => ({
redirect: (path: string) => mockRedirect(path),
}))
describe('snippet detail redirect page', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should redirect legacy snippet detail routes to orchestrate', async () => {
await Page({
params: Promise.resolve({ snippetId: 'snippet-1' }),
})
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
})
})

View File

@@ -0,0 +1,11 @@
import { redirect } from 'next/navigation'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
redirect(`/snippets/${snippetId}/orchestrate`)
}
export default Page

View File

@@ -0,0 +1,7 @@
import Apps from '@/app/components/apps'
const SnippetsPage = () => {
return <Apps pageType="snippets" />
}
export default SnippetsPage

View File

@@ -165,6 +165,21 @@ describe('AppDetailNav', () => {
)
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
})
it('should render custom header and navigation when provided', () => {
render(
<AppDetailNav
navigation={navigation}
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
/>,
)
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
})
})
describe('Workflow canvas mode', () => {

View File

@@ -27,12 +27,16 @@ export type IAppDetailNavProps = {
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}
const AppDetailNav = ({
navigation,
extraInfo,
iconType = 'app',
renderHeader,
renderNavigation,
}: IAppDetailNavProps) => {
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
@@ -104,10 +108,11 @@ const AppDetailNav = ({
expand ? 'p-2' : 'p-1',
)}
>
{iconType === 'app' && (
{renderHeader?.(appSidebarExpand)}
{!renderHeader && iconType === 'app' && (
<AppInfo expand={expand} />
)}
{iconType !== 'app' && (
{!renderHeader && iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
@@ -136,7 +141,8 @@ const AppDetailNav = ({
expand ? 'px-3 py-2' : 'p-3',
)}
>
{navigation.map((item, index) => {
{renderNavigation?.(appSidebarExpand)}
{!renderNavigation && navigation.map((item, index) => {
return (
<NavLink
key={index}

View File

@@ -262,4 +262,20 @@ describe('NavLink Animation and Layout Issues', () => {
expect(iconWrapper).toHaveClass('-ml-1')
})
})
describe('Button Mode', () => {
it('should render as an interactive button when href is omitted', () => {
const onClick = vi.fn()
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
const buttonElement = screen.getByText('Orchestrate').closest('button')
expect(buttonElement).not.toBeNull()
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
buttonElement?.click()
expect(onClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -14,13 +14,15 @@ export type NavIcon = React.ComponentType<
export type NavLinkProps = {
name: string
href: string
href?: string
iconMap: {
selected: NavIcon
normal: NavIcon
}
mode?: string
disabled?: boolean
active?: boolean
onClick?: () => void
}
const NavLink = ({
@@ -29,6 +31,8 @@ const NavLink = ({
iconMap,
mode = 'expand',
disabled = false,
active,
onClick,
}: NavLinkProps) => {
const segment = useSelectedLayoutSegment()
const formattedSegment = (() => {
@@ -39,8 +43,11 @@ const NavLink = ({
return res
})()
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
const NavIcon = isActive ? iconMap.selected : iconMap.normal
const linkClassName = cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
const renderIcon = () => (
<div className={cn(mode !== 'expand' && '-ml-1')}>
@@ -70,13 +77,32 @@ const NavLink = ({
)
}
if (!href) {
return (
<button
key={name}
type="button"
className={linkClassName}
title={mode === 'collapse' ? name : ''}
onClick={onClick}
>
{renderIcon()}
<span
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
? 'ml-2 max-w-none opacity-100'
: 'ml-0 max-w-0 opacity-0')}
>
{name}
</span>
</button>
)
}
return (
<Link
key={name}
href={href}
className={cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')}
className={linkClassName}
title={mode === 'collapse' ? name : ''}
>
{renderIcon()}

View File

@@ -0,0 +1,53 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import AppIcon from '@/app/components/base/app-icon'
import Badge from '@/app/components/base/badge'
import { cn } from '@/utils/classnames'
type SnippetInfoProps = {
expand: boolean
snippet: SnippetDetail
}
const SnippetInfo = ({
expand,
snippet,
}: SnippetInfoProps) => {
return (
<div className={cn('flex flex-col', expand ? '' : 'p-1')}>
<div className="flex flex-col gap-2 p-2">
<div className="flex items-start gap-3">
<div className={cn(!expand && 'ml-1')}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType="emoji"
icon={snippet.icon}
background={snippet.iconBackground}
/>
</div>
{expand && (
<div className="min-w-0 flex-1">
<div className="truncate text-text-secondary system-md-semibold">
{snippet.name}
</div>
{snippet.status && (
<div className="pt-1">
<Badge>{snippet.status}</Badge>
</div>
)}
</div>
)}
</div>
{expand && snippet.description && (
<p className="line-clamp-3 text-text-tertiary system-xs-regular">
{snippet.description}
</p>
)}
</div>
</div>
)
}
export default React.memo(SnippetInfo)

View File

@@ -137,4 +137,31 @@ describe('SelectDataSet', () => {
expect(screen.getByRole('link', { name: 'appDebug.feature.dataSet.toCreate' })).toHaveAttribute('href', '/datasets/create')
expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled()
})
it('uses selectedIds as the initial modal selection', async () => {
const datasetOne = makeDataset({
id: 'set-1',
name: 'Dataset One',
})
mockUseInfiniteDatasets.mockReturnValue({
data: { pages: [{ data: [datasetOne] }] },
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
})
const onSelect = vi.fn()
await act(async () => {
render(<SelectDataSet {...baseProps} onSelect={onSelect} selectedIds={['set-1']} />)
})
expect(screen.getByText('1 appDebug.feature.dataSet.selected')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
})
expect(onSelect).toHaveBeenCalledWith([datasetOne])
})
})

View File

@@ -4,7 +4,7 @@ import type { DataSet } from '@/models/datasets'
import { useInfiniteScroll } from 'ahooks'
import Link from 'next/link'
import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Badge from '@/app/components/base/badge'
@@ -31,17 +31,21 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
onSelect,
}) => {
const { t } = useTranslation()
const [selected, setSelected] = useState<DataSet[]>([])
const [selectedIdsInModal, setSelectedIdsInModal] = useState(() => selectedIds)
const canSelectMulti = true
const { formatIndexingTechniqueAndMethod } = useKnowledge()
const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteDatasets(
{ page: 1 },
{ enabled: isShow, staleTime: 0, refetchOnMount: 'always' },
)
const pages = data?.pages || []
const datasets = useMemo(() => {
const pages = data?.pages || []
return pages.flatMap(page => page.data.filter(item => item.indexing_technique || item.provider === 'external'))
}, [pages])
}, [data])
const datasetMap = useMemo(() => new Map(datasets.map(item => [item.id, item])), [datasets])
const selected = useMemo(() => {
return selectedIdsInModal.map(id => datasetMap.get(id) || ({ id } as DataSet))
}, [datasetMap, selectedIdsInModal])
const hasNoData = !isLoading && datasets.length === 0
const listRef = useRef<HTMLDivElement>(null)
@@ -61,50 +65,14 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
},
)
const prevSelectedIdsRef = useRef<string[]>([])
const hasUserModifiedSelectionRef = useRef(false)
useEffect(() => {
if (isShow)
hasUserModifiedSelectionRef.current = false
}, [isShow])
useEffect(() => {
const prevSelectedIds = prevSelectedIdsRef.current
const idsChanged = selectedIds.length !== prevSelectedIds.length
|| selectedIds.some((id, idx) => id !== prevSelectedIds[idx])
if (!selectedIds.length && (!hasUserModifiedSelectionRef.current || idsChanged)) {
setSelected([])
prevSelectedIdsRef.current = selectedIds
hasUserModifiedSelectionRef.current = false
return
}
if (!idsChanged && hasUserModifiedSelectionRef.current)
return
setSelected((prev) => {
const prevMap = new Map(prev.map(item => [item.id, item]))
const nextSelected = selectedIds
.map(id => datasets.find(item => item.id === id) || prevMap.get(id))
.filter(Boolean) as DataSet[]
return nextSelected
})
prevSelectedIdsRef.current = selectedIds
hasUserModifiedSelectionRef.current = false
}, [datasets, selectedIds])
const toggleSelect = (dataSet: DataSet) => {
hasUserModifiedSelectionRef.current = true
const isSelected = selected.some(item => item.id === dataSet.id)
if (isSelected) {
setSelected(selected.filter(item => item.id !== dataSet.id))
}
else {
if (canSelectMulti)
setSelected([...selected, dataSet])
else
setSelected([dataSet])
}
setSelectedIdsInModal((prev) => {
const isSelected = prev.includes(dataSet.id)
if (isSelected)
return prev.filter(id => id !== dataSet.id)
return canSelectMulti ? [...prev, dataSet.id] : [dataSet.id]
})
}
const handleSelect = () => {
@@ -126,7 +94,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
{hasNoData && (
<div
className="mt-6 flex h-[128px] items-center justify-center space-x-1 rounded-lg border text-[13px]"
className="mt-6 flex h-[128px] items-center justify-center space-x-1 rounded-lg border text-[13px]"
style={{
background: 'rgba(0, 0, 0, 0.02)',
borderColor: 'rgba(0, 0, 0, 0.02',
@@ -145,7 +113,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
key={item.id}
className={cn(
'flex h-10 cursor-pointer items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 shadow-xs hover:border-components-panel-border hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
selected.some(i => i.id === item.id) && 'border-[1.5px] border-components-option-card-option-selected-border bg-state-accent-hover shadow-xs hover:border-components-option-card-option-selected-border hover:bg-state-accent-hover hover:shadow-xs',
selectedIdsInModal.includes(item.id) && 'border-[1.5px] border-components-option-card-option-selected-border bg-state-accent-hover shadow-xs hover:border-components-option-card-option-selected-border hover:bg-state-accent-hover hover:shadow-xs',
!item.embedding_available && 'hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg hover:shadow-xs',
)}
onClick={() => {
@@ -195,7 +163,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
)}
{!isLoading && (
<div className="mt-8 flex items-center justify-between">
<div className="text-sm font-medium text-text-secondary">
<div className="text-sm font-medium text-text-secondary">
{selected.length > 0 && `${selected.length} ${t('feature.dataSet.selected', { ns: 'appDebug' })}`}
</div>
<div className="flex space-x-2">

View File

@@ -1,4 +1,4 @@
import { act, fireEvent, screen } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { renderWithNuqs } from '@/test/nuqs-testing'
@@ -6,19 +6,15 @@ import { AppModeEnum } from '@/types/app'
import List from '../list'
const mockReplace = vi.fn()
const mockRouter = { replace: mockReplace }
vi.mock('next/navigation', () => ({
useRouter: () => mockRouter,
useSearchParams: () => new URLSearchParams(''),
}))
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
const mockIsLoadingCurrentWorkspace = vi.fn(() => false)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace(),
}),
}))
@@ -36,6 +32,7 @@ const mockQueryState = {
keywords: '',
isCreatedByMe: false,
}
vi.mock('../hooks/use-apps-query-state', () => ({
default: () => ({
query: mockQueryState,
@@ -45,6 +42,7 @@ vi.mock('../hooks/use-apps-query-state', () => ({
let mockOnDSLFileDropped: ((file: File) => void) | null = null
let mockDragging = false
vi.mock('../hooks/use-dsl-drag-drop', () => ({
useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
mockOnDSLFileDropped = onDSLFileDropped
@@ -59,6 +57,7 @@ const mockServiceState = {
error: null as Error | null,
hasNextPage: false,
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
}
@@ -100,6 +99,7 @@ vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: defaultAppData,
isLoading: mockServiceState.isLoading,
isFetching: mockServiceState.isFetching,
isFetchingNextPage: mockServiceState.isFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockServiceState.hasNextPage,
@@ -133,13 +133,21 @@ vi.mock('next/dynamic', () => ({
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
}
}
if (fnString.includes('create-from-dsl-modal')) {
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
return React.createElement(
'div',
{ 'data-testid': 'create-dsl-modal' },
React.createElement('button', { 'data-testid': 'close-dsl-modal', 'onClick': onClose }, 'Close'),
React.createElement('button', { 'data-testid': 'success-dsl-modal', 'onClick': onSuccess }, 'Success'),
)
}
}
return () => null
},
}))
@@ -188,9 +196,8 @@ beforeAll(() => {
} as unknown as typeof IntersectionObserver
})
// Render helper wrapping with shared nuqs testing helper.
const renderList = (searchParams = '') => {
return renderWithNuqs(<List />, { searchParams })
const renderList = (props: React.ComponentProps<typeof List> = {}, searchParams = '') => {
return renderWithNuqs(<List {...props} />, { searchParams })
}
describe('List', () => {
@@ -202,11 +209,13 @@ describe('List', () => {
})
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
mockIsLoadingCurrentWorkspace.mockReturnValue(false)
mockDragging = false
mockOnDSLFileDropped = null
mockServiceState.error = null
mockServiceState.hasNextPage = false
mockServiceState.isLoading = false
mockServiceState.isFetching = false
mockServiceState.isFetchingNextPage = false
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
@@ -215,372 +224,94 @@ describe('List', () => {
localStorage.clear()
})
describe('Rendering', () => {
it('should render without crashing', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render tab slider with all app types', () => {
describe('Apps Mode', () => {
it('should render the apps route switch, dropdown filters, and app cards', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
it('should render search input', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render tag filter', () => {
renderList()
expect(screen.getByRole('link', { name: 'app.studio.apps' })).toHaveAttribute('href', '/apps')
expect(screen.getByRole('link', { name: 'workflow.tabs.snippets' })).toHaveAttribute('href', '/snippets')
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should render created by me checkbox', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
renderList()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should render new app card for editors', () => {
renderList()
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
it('should render footer when branding is disabled', () => {
renderList()
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
it('should render drop DSL hint for editors', () => {
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
})
describe('Tab Navigation', () => {
it('should update URL when workflow tab is clicked', async () => {
it('should update the category query when selecting an app type from the dropdown', async () => {
const { onUrlUpdate } = renderList()
fireEvent.click(screen.getByText('app.types.workflow'))
fireEvent.click(screen.getByText('app.studio.filters.types'))
fireEvent.click(await screen.findByText('app.types.workflow'))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
})
it('should update URL when all tab is clicked', async () => {
const { onUrlUpdate } = renderList('?category=workflow')
it('should keep the creators dropdown visual-only and not update app query state', async () => {
renderList()
fireEvent.click(screen.getByText('app.types.all'))
fireEvent.click(screen.getByText('app.studio.filters.creators'))
fireEvent.click(await screen.findByText('Evan'))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
// nuqs removes the default value ('all') from URL params
expect(lastCall.searchParams.has('category')).toBe(false)
expect(mockSetQuery).not.toHaveBeenCalled()
expect(screen.getByText('app.studio.filters.creators +1')).toBeInTheDocument()
})
it('should render and close the DSL import modal when a file is dropped', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
})
describe('Search Functionality', () => {
it('should render search input field', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
describe('Snippets Mode', () => {
it('should render the snippets create card and fake snippet card', () => {
renderList({ pageType: 'snippets' })
expect(screen.getByText('snippet.create')).toBeInTheDocument()
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
expect(screen.getByText('Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Tone Rewriter/i })).toHaveAttribute('href', '/snippets/snippet-1')
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()
})
it('should handle search input change', () => {
renderList()
it('should filter local snippets by the search input and show the snippet empty state', () => {
renderList({ pageType: 'snippets' })
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
fireEvent.change(input, { target: { value: 'missing snippet' } })
expect(mockSetQuery).toHaveBeenCalled()
expect(screen.queryByText('Tone Rewriter')).not.toBeInTheDocument()
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
})
it('should handle search clear button click', () => {
mockQueryState.keywords = 'existing search'
it('should not render app-only controls in snippets mode', () => {
renderList({ pageType: 'snippets' })
renderList()
const clearButton = document.querySelector('.group')
expect(clearButton).toBeInTheDocument()
if (clearButton)
fireEvent.click(clearButton)
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Tag Filter', () => {
it('should render tag filter component', () => {
renderList()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
})
describe('Created By Me Filter', () => {
it('should render checkbox with correct label', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
expect(screen.queryByText('app.studio.filters.types')).not.toBeInTheDocument()
expect(screen.queryByText('common.tag.placeholder')).not.toBeInTheDocument()
expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument()
})
it('should handle checkbox change', () => {
renderList()
it('should reserve the infinite-scroll anchor without fetching more pages', () => {
renderList({ pageType: 'snippets' })
const checkbox = screen.getByTestId('checkbox-undefined')
fireEvent.click(checkbox)
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Non-Editor User', () => {
it('should not render new app card for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
})
it('should not render drop DSL hint for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
})
})
describe('Dataset Operator Behavior', () => {
it('should not trigger redirect at component level for dataset operators', () => {
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
renderList()
expect(mockReplace).not.toHaveBeenCalled()
})
})
describe('Local Storage Refresh', () => {
it('should call refetch when refresh key is set in localStorage', () => {
localStorage.setItem('needRefreshAppList', '1')
renderList()
expect(mockRefetch).toHaveBeenCalled()
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
})
})
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = renderWithNuqs(<List />)
expect(screen.getByText('app.types.all')).toBeInTheDocument()
rerender(<List />)
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render app cards correctly', () => {
renderList()
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
it('should render with all filter options visible', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
})
describe('Dragging State', () => {
it('should show drop hint when DSL feature is enabled for editors', () => {
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
it('should render dragging state overlay when dragging', () => {
mockDragging = true
const { container } = renderList()
expect(container).toBeInTheDocument()
})
})
describe('App Type Tabs', () => {
it('should render all app type tabs', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
it('should update URL for each app type tab click', async () => {
const { onUrlUpdate } = renderList()
const appTypeTexts = [
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
{ mode: AppModeEnum.CHAT, text: 'app.types.chatbot' },
{ mode: AppModeEnum.AGENT_CHAT, text: 'app.types.agent' },
{ mode: AppModeEnum.COMPLETION, text: 'app.types.completion' },
]
for (const { mode, text } of appTypeTexts) {
onUrlUpdate.mockClear()
fireEvent.click(screen.getByText(text))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(lastCall.searchParams.get('category')).toBe(mode)
}
})
})
describe('App List Display', () => {
it('should display all app cards from data', () => {
renderList()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should display app names correctly', () => {
renderList()
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
})
describe('Footer Visibility', () => {
it('should render footer when branding is disabled', () => {
renderList()
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
})
describe('DSL File Drop', () => {
it('should handle DSL file drop and show modal', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
})
it('should close DSL modal when onClose is called', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
it('should close DSL modal and refetch when onSuccess is called', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('success-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
expect(mockRefetch).toHaveBeenCalled()
})
})
describe('Infinite Scroll', () => {
it('should call fetchNextPage when intersection observer triggers', () => {
mockServiceState.hasNextPage = true
renderList()
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).toHaveBeenCalled()
})
it('should not call fetchNextPage when not intersecting', () => {
mockServiceState.hasNextPage = true
renderList()
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: false } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('should not call fetchNextPage when loading', () => {
mockServiceState.hasNextPage = true
mockServiceState.isLoading = true
renderList()
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
})
describe('Error State', () => {
it('should handle error state in useEffect', () => {
mockServiceState.error = new Error('Test error')
const { container } = renderList()
expect(container).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,15 @@
import { parseAsStringLiteral } from 'nuqs'
import { AppModes } from '@/types/app'
export const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
export const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })

View File

@@ -0,0 +1,71 @@
'use client'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { isAppListCategory } from './app-type-filter-shared'
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
type AppTypeFilterProps = {
activeTab: import('./app-type-filter-shared').AppListCategory
onChange: (value: import('./app-type-filter-shared').AppListCategory) => void
}
const AppTypeFilter = ({
activeTab,
onChange,
}: AppTypeFilterProps) => {
const { t } = useTranslation()
const options = useMemo(() => ([
{ value: 'all', text: t('types.all', { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
]), [t])
const activeOption = options.find(option => option.value === activeTab)
const triggerLabel = activeTab === 'all' ? t('studio.filters.types', { ns: 'app' }) : activeOption?.text
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(chipClassName, activeTab !== 'all' && 'shadow-xs')}
/>
)}
>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
<span>{triggerLabel}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
<DropdownMenuRadioGroup value={activeTab} onValueChange={value => isAppListCategory(value) && onChange(value)}>
{options.map(option => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} />
<span>{option.text}</span>
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default AppTypeFilter

View File

@@ -0,0 +1,128 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuCheckboxItemIndicator,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { cn } from '@/utils/classnames'
type CreatorOption = {
id: string
name: string
isYou?: boolean
avatarClassName: string
}
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
const creatorOptions: CreatorOption[] = [
{ id: 'evan', name: 'Evan', isYou: true, avatarClassName: 'bg-gradient-to-br from-[#ff9b3f] to-[#ff4d00]' },
{ id: 'jack', name: 'Jack', avatarClassName: 'bg-gradient-to-br from-[#fde68a] to-[#d6d3d1]' },
{ id: 'gigi', name: 'Gigi', avatarClassName: 'bg-gradient-to-br from-[#f9a8d4] to-[#a78bfa]' },
{ id: 'alice', name: 'Alice', avatarClassName: 'bg-gradient-to-br from-[#93c5fd] to-[#4f46e5]' },
{ id: 'mandy', name: 'Mandy', avatarClassName: 'bg-gradient-to-br from-[#374151] to-[#111827]' },
]
const CreatorsFilter = () => {
const { t } = useTranslation()
const [selectedCreatorIds, setSelectedCreatorIds] = useState<string[]>([])
const [keywords, setKeywords] = useState('')
const filteredCreators = useMemo(() => {
const normalizedKeywords = keywords.trim().toLowerCase()
if (!normalizedKeywords)
return creatorOptions
return creatorOptions.filter(creator => creator.name.toLowerCase().includes(normalizedKeywords))
}, [keywords])
const selectedCount = selectedCreatorIds.length
const triggerLabel = selectedCount > 0
? `${t('studio.filters.creators', { ns: 'app' })} +${selectedCount}`
: t('studio.filters.creators', { ns: 'app' })
const toggleCreator = useCallback((creatorId: string) => {
setSelectedCreatorIds((prev) => {
if (prev.includes(creatorId))
return prev.filter(id => id !== creatorId)
return [...prev, creatorId]
})
}, [])
const resetCreators = useCallback(() => {
setSelectedCreatorIds([])
setKeywords('')
}, [])
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(chipClassName, selectedCount > 0 && 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs')}
/>
)}
>
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
<span>{triggerLabel}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
<div className="flex items-center gap-2 p-2 pb-1">
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
/>
<button
type="button"
className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={resetCreators}
>
{t('studio.filters.reset', { ns: 'app' })}
</button>
</div>
<div className="px-1 pb-1">
<DropdownMenuCheckboxItem
checked={selectedCreatorIds.length === 0}
onCheckedChange={resetCreators}
>
<span aria-hidden className="i-ri-user-line h-4 w-4 shrink-0 text-text-tertiary" />
<span>{t('studio.filters.allCreators', { ns: 'app' })}</span>
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
{filteredCreators.map(creator => (
<DropdownMenuCheckboxItem
key={creator.id}
checked={selectedCreatorIds.includes(creator.id)}
onCheckedChange={() => toggleCreator(creator.id)}
>
<span className={cn('h-5 w-5 shrink-0 rounded-full border border-white', creator.avatarClassName)} />
<span className="flex min-w-0 grow items-center justify-between gap-2">
<span className="truncate">{creator.name}</span>
{creator.isYou && (
<span className="shrink-0 text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
)}
</span>
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default CreatorsFilter

View File

@@ -14,10 +14,20 @@ import CreateAppModal from '../explore/create-app-modal'
import TryApp from '../explore/try-app'
import List from './list'
const Apps = () => {
export type StudioPageType = 'apps' | 'snippets'
type AppsProps = {
pageType?: StudioPageType
}
const Apps = ({
pageType = 'apps',
}: AppsProps) => {
const { t } = useTranslation()
useDocumentTitle(t('menus.apps', { ns: 'common' }))
useDocumentTitle(pageType === 'apps'
? t('menus.apps', { ns: 'common' })
: t('tabs.snippets', { ns: 'workflow' }))
useEducationInit()
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
@@ -101,7 +111,7 @@ const Apps = () => {
}}
>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<List controlRefreshList={controlRefreshList} />
<List controlRefreshList={controlRefreshList} pageType={pageType} />
{isShowTryAppPanel && (
<TryApp
appId={currentTryAppParams?.appId || ''}

View File

@@ -1,25 +1,30 @@
'use client'
import type { FC } from 'react'
import type { StudioPageType } from '.'
import type { SnippetListItem } from '@/models/snippet'
import type { App } from '@/types/app'
import { useDebounceFn } from 'ahooks'
import dynamic from 'next/dynamic'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import TabSliderNew from '@/app/components/base/tab-slider-new'
import TagFilter from '@/app/components/base/tag-management/filter'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { CheckModal } from '@/hooks/use-pay'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum, AppModes } from '@/types/app'
import { getSnippetListMock } from '@/service/use-snippets'
import { cn } from '@/utils/classnames'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import AppTypeFilter from './app-type-filter'
import { parseAsAppListCategory } from './app-type-filter-shared'
import CreatorsFilter from './creators-filter'
import Empty from './empty'
import Footer from './footer'
import useAppsQueryState from './hooks/use-apps-query-state'
@@ -33,25 +38,104 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
ssr: false,
})
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
const StudioRouteSwitch = ({ pageType, appsLabel, snippetsLabel }: { pageType: StudioPageType, appsLabel: string, snippetsLabel: string }) => {
return (
<div className="flex items-center rounded-lg border-[0.5px] border-divider-subtle bg-[rgba(200,206,218,0.2)] p-[1px]">
<Link
href="/apps"
className={cn(
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
pageType === 'apps' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
pageType !== 'apps' && 'font-medium',
)}
>
{appsLabel}
</Link>
<Link
href="/snippets"
className={cn(
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
pageType === 'snippets' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
pageType !== 'snippets' && 'font-medium',
)}
>
{snippetsLabel}
</Link>
</div>
)
}
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })
const SnippetCreateCard = () => {
const { t } = useTranslation('snippet')
return (
<div className="relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity">
<div className="grow rounded-t-xl p-2">
<div className="px-6 pb-1 pt-2 text-xs font-medium leading-[18px] text-text-tertiary">{t('create')}</div>
<div className="mb-1 flex w-full items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary">
<span aria-hidden className="i-ri-sticky-note-add-line mr-2 h-4 w-4 shrink-0" />
{t('newApp.startFromBlank', { ns: 'app' })}
</div>
<div className="flex w-full items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary">
<span aria-hidden className="i-ri-file-upload-line mr-2 h-4 w-4 shrink-0" />
{t('importDSL', { ns: 'app' })}
</div>
</div>
</div>
)
}
const SnippetCard = ({
snippet,
}: {
snippet: SnippetListItem
}) => {
return (
<Link href={`/snippets/${snippet.id}/orchestrate`} className="group col-span-1">
<article className="relative inline-flex h-[160px] w-full flex-col rounded-xl border border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg">
{snippet.status && (
<div className="absolute right-0 top-0 rounded-bl-lg rounded-tr-xl bg-background-default-dimmed px-2 py-1 text-[10px] font-medium uppercase leading-3 text-text-placeholder">
{snippet.status}
</div>
)}
<div className="flex h-[66px] items-center gap-3 px-[14px] pb-3 pt-[14px]">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-divider-regular text-xl text-white" style={{ background: snippet.iconBackground }}>
<span aria-hidden>{snippet.icon}</span>
</div>
<div className="w-0 grow py-[1px]">
<div className="truncate text-sm font-semibold leading-5 text-text-secondary" title={snippet.name}>
{snippet.name}
</div>
</div>
</div>
<div className="h-[58px] px-[14px] text-xs leading-normal text-text-tertiary">
<div className="line-clamp-2" title={snippet.description}>
{snippet.description}
</div>
</div>
<div className="mt-auto flex items-center gap-1 px-[14px] pb-3 pt-2 text-xs leading-4 text-text-tertiary">
<span className="truncate">{snippet.author}</span>
<span>·</span>
<span className="truncate">{snippet.updatedAt}</span>
<span>·</span>
<span className="truncate">{snippet.usage}</span>
</div>
</article>
</Link>
)
}
type Props = {
controlRefreshList?: number
pageType?: StudioPageType
}
const List: FC<Props> = ({
controlRefreshList = 0,
pageType = 'apps',
}) => {
const { t } = useTranslation()
const isAppsPage = pageType === 'apps'
const { systemFeatures } = useGlobalPublicStore()
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
@@ -61,18 +145,21 @@ const List: FC<Props> = ({
)
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
const [searchKeywords, setSearchKeywords] = useState(keywords)
const newAppCardRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [appKeywords, setAppKeywords] = useState(keywords)
const [snippetKeywords, setSnippetKeywords] = useState('')
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
const setKeywords = useCallback((keywords: string) => {
setQuery(prev => ({ ...prev, keywords }))
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const newAppCardRef = useRef<HTMLDivElement>(null)
const setKeywords = useCallback((nextKeywords: string) => {
setQuery(prev => ({ ...prev, keywords: nextKeywords }))
}, [setQuery])
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs }))
const setTagIDs = useCallback((nextTagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs: nextTagIDs }))
}, [setQuery])
const handleDSLFileDropped = useCallback((file: File) => {
@@ -83,15 +170,15 @@ const List: FC<Props> = ({
const { dragging } = useDSLDragDrop({
onDSLFileDropped: handleDSLFileDropped,
containerRef,
enabled: isCurrentWorkspaceEditor,
enabled: isAppsPage && isCurrentWorkspaceEditor,
})
const appListQueryParams = {
page: 1,
limit: 30,
name: searchKeywords,
name: appKeywords,
tag_ids: tagIDs,
is_created_by_me: isCreatedByMe,
is_created_by_me: queryIsCreatedByMe,
...(activeTab !== 'all' ? { mode: activeTab } : {}),
}
@@ -104,48 +191,40 @@ const List: FC<Props> = ({
hasNextPage,
error,
refetch,
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
} = useInfiniteAppList(appListQueryParams, {
enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator,
})
useEffect(() => {
if (controlRefreshList > 0) {
if (isAppsPage && controlRefreshList > 0)
refetch()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlRefreshList])
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="i-ri-apps-2-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="i-ri-exchange-2-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="i-ri-robot-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="i-ri-file-4-line mr-1 h-[14px] w-[14px]" /> },
]
}, [controlRefreshList, isAppsPage, refetch])
useEffect(() => {
if (!isAppsPage)
return
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
refetch()
}
}, [refetch])
}, [isAppsPage, refetch])
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return
const hasMore = hasNextPage ?? true
const hasMore = isAppsPage ? (hasNextPage ?? true) : false
let observer: IntersectionObserver | undefined
if (error) {
if (observer)
observer.disconnect()
observer?.disconnect()
return
}
if (anchorRef.current && containerRef.current) {
// Calculate dynamic rootMargin: clamps to 100-200px range, using 20% of container height as the base value for better responsiveness
const containerHeight = containerRef.current.clientHeight
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
@@ -153,110 +232,148 @@ const List: FC<Props> = ({
}, {
root: containerRef.current,
rootMargin: `${dynamicMargin}px`,
threshold: 0.1, // Trigger when 10% of the anchor element is visible
threshold: 0.1,
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
}, [error, fetchNextPage, hasNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading])
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
const { run: handleAppSearch } = useDebounceFn((value: string) => {
setAppKeywords(value)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const { run: handleTagsUpdate } = useDebounceFn(() => {
setTagIDs(tagFilterValue)
const handleKeywordsChange = useCallback((value: string) => {
if (isAppsPage) {
setKeywords(value)
handleAppSearch(value)
return
}
setSnippetKeywords(value)
}, [handleAppSearch, isAppsPage, setKeywords])
const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => {
setTagIDs(value)
}, { wait: 500 })
const handleTagsChange = (value: string[]) => {
const handleTagsChange = useCallback((value: string[]) => {
setTagFilterValue(value)
handleTagsUpdate()
}
handleTagsUpdate(value)
}, [handleTagsUpdate])
const handleCreatedByMeChange = useCallback(() => {
const newValue = !isCreatedByMe
setIsCreatedByMe(newValue)
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
}, [isCreatedByMe, setQuery])
const appItems = useMemo<App[]>(() => {
return (data?.pages ?? []).flatMap(({ data: apps }) => apps)
}, [data?.pages])
const pages = data?.pages ?? []
const hasAnyApp = (pages[0]?.total ?? 0) > 0
// Show skeleton during initial load or when refetching with no previous data
const showSkeleton = isLoading || (isFetching && pages.length === 0)
const snippetItems = useMemo(() => getSnippetListMock(), [])
const filteredSnippetItems = useMemo(() => {
const normalizedKeywords = snippetKeywords.trim().toLowerCase()
if (!normalizedKeywords)
return snippetItems
return snippetItems.filter(item =>
item.name.toLowerCase().includes(normalizedKeywords)
|| item.description.toLowerCase().includes(normalizedKeywords),
)
}, [snippetItems, snippetKeywords])
const showSkeleton = isAppsPage && (isLoading || (isFetching && data?.pages?.length === 0))
const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0
const hasAnySnippet = filteredSnippetItems.length > 0
const currentKeywords = isAppsPage ? keywords : snippetKeywords
return (
<>
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
{dragging && (
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2">
</div>
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2" />
)}
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pb-5 pt-7">
<TabSliderNew
value={activeTab}
onChange={(nextValue) => {
if (isAppListCategory(nextValue))
setActiveTab(nextValue)
}}
options={options}
/>
<div className="flex items-center gap-2">
<CheckboxWithLabel
className="mr-2"
label={t('showMyCreatedAppsOnly', { ns: 'app' })}
isChecked={isCreatedByMe}
onChange={handleCreatedByMeChange}
<div className="flex flex-wrap items-center gap-2">
<StudioRouteSwitch
pageType={pageType}
appsLabel={t('studio.apps', { ns: 'app' })}
snippetsLabel={t('tabs.snippets', { ns: 'workflow' })}
/>
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
{isAppsPage && (
<AppTypeFilter
activeTab={activeTab}
onChange={(value) => {
void setActiveTab(value)
}}
/>
)}
<CreatorsFilter />
{isAppsPage && (
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
)}
</div>
<div className="flex items-center gap-2">
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-[200px]"
value={keywords}
placeholder={isAppsPage ? undefined : t('tabs.searchSnippets', { ns: 'workflow' })}
value={currentKeywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
</div>
</div>
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6',
!hasAnyApp && 'overflow-hidden',
isAppsPage && !hasAnyApp && 'overflow-hidden',
)}
>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
isAppsPage
? (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
)
: <SnippetCreateCard />
)}
{(() => {
if (showSkeleton)
return <AppCardSkeleton count={6} />
if (hasAnyApp) {
return pages.flatMap(({ data: apps }) => apps).map(app => (
<AppCard key={app.id} app={app} onRefresh={refetch} />
))
}
{showSkeleton && <AppCardSkeleton count={6} />}
// No apps - show empty state
return <Empty />
})()}
{isFetchingNextPage && (
{!showSkeleton && isAppsPage && hasAnyApp && appItems.map(app => (
<AppCard key={app.id} app={app} onRefresh={refetch} />
))}
{!showSkeleton && !isAppsPage && hasAnySnippet && filteredSnippetItems.map(snippet => (
<SnippetCard key={snippet.id} snippet={snippet} />
))}
{!showSkeleton && isAppsPage && !hasAnyApp && <Empty />}
{!showSkeleton && !isAppsPage && !hasAnySnippet && (
<div className="col-span-full flex min-h-[240px] items-center justify-center rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-6 text-center text-sm text-text-tertiary">
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
</div>
)}
{isAppsPage && isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
</div>
{isCurrentWorkspaceEditor && (
{isAppsPage && isCurrentWorkspaceEditor && (
<div
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
className={cn(
'flex items-center justify-center gap-2 py-4',
dragging ? 'text-text-accent' : 'text-text-quaternary',
)}
role="region"
aria-label={t('newApp.dropDSLToCreateApp', { ns: 'app' })}
>
@@ -264,17 +381,18 @@ const List: FC<Props> = ({
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
</div>
)}
{!systemFeatures.branding.enabled && (
<Footer />
)}
<CheckModal />
<div ref={anchorRef} className="h-0"> </div>
{showTagManagementModal && (
{isAppsPage && showTagManagementModal && (
<TagManagementModal type="app" show={showTagManagementModal} />
)}
</div>
{showCreateFromDSLModal && (
{isAppsPage && showCreateFromDSLModal && (
<CreateFromDSLModal
show={showCreateFromDSLModal}
onClose={() => {

View File

@@ -137,5 +137,11 @@ describe('ScoreThresholdItem', () => {
const input = screen.getByRole('textbox')
expect(input).toHaveValue('1')
})
it('should fall back to default value when value is undefined', () => {
render(<ScoreThresholdItem {...defaultProps} value={undefined} />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('0.7')
})
})
})

View File

@@ -6,7 +6,7 @@ import ParamItem from '.'
type Props = {
className?: string
value: number
value?: number
onChange: (key: string, value: number) => void
enable: boolean
hasSwitch?: boolean
@@ -20,6 +20,18 @@ const VALUE_LIMIT = {
max: 1,
}
const normalizeScoreThreshold = (value?: number): number => {
const normalizedValue = typeof value === 'number' && Number.isFinite(value)
? value
: VALUE_LIMIT.default
const roundedValue = Number.parseFloat(normalizedValue.toFixed(2))
return Math.min(
VALUE_LIMIT.max,
Math.max(VALUE_LIMIT.min, roundedValue),
)
}
const ScoreThresholdItem: FC<Props> = ({
className,
value,
@@ -29,16 +41,10 @@ const ScoreThresholdItem: FC<Props> = ({
onSwitchChange,
}) => {
const { t } = useTranslation()
const handleParamChange = (key: string, value: number) => {
let notOutRangeValue = Number.parseFloat(value.toFixed(2))
notOutRangeValue = Math.max(VALUE_LIMIT.min, notOutRangeValue)
notOutRangeValue = Math.min(VALUE_LIMIT.max, notOutRangeValue)
onChange(key, notOutRangeValue)
const handleParamChange = (key: string, nextValue: number) => {
onChange(key, normalizeScoreThreshold(nextValue))
}
const safeValue = Math.min(
VALUE_LIMIT.max,
Math.max(VALUE_LIMIT.min, Number.parseFloat(value.toFixed(2))),
)
const safeValue = normalizeScoreThreshold(value)
return (
<ParamItem

View File

@@ -0,0 +1,112 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import Evaluation from '..'
import { getEvaluationMockConfig } from '../mock'
import { useEvaluationStore } from '../store'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({
data: [{
provider: 'openai',
models: [{ model: 'gpt-4o-mini' }],
}],
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => (
<div data-testid="evaluation-model-selector">
{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'empty'}
</div>
),
}))
describe('Evaluation', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {} })
})
it('should search, add metrics, and create a batch history record', async () => {
vi.useFakeTimers()
render(<Evaluation resourceType="workflow" resourceId="app-1" />)
expect(screen.getByTestId('evaluation-model-selector')).toHaveTextContent('openai:gpt-4o-mini')
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
expect(screen.getByTestId('evaluation-metric-loading')).toBeInTheDocument()
await act(async () => {
vi.advanceTimersByTime(200)
})
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchPlaceholder'), {
target: { value: 'does-not-exist' },
})
await act(async () => {
vi.advanceTimersByTime(200)
})
expect(screen.getByText('evaluation.metrics.noResults')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchPlaceholder'), {
target: { value: 'faith' },
})
await act(async () => {
vi.advanceTimersByTime(200)
})
fireEvent.click(screen.getByRole('button', { name: /Faithfulness/i }))
expect(screen.getAllByText('Faithfulness').length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.batch.run' }))
expect(screen.getByText('evaluation.batch.status.running')).toBeInTheDocument()
await act(async () => {
vi.advanceTimersByTime(1300)
})
expect(screen.getByText('evaluation.batch.status.success')).toBeInTheDocument()
expect(screen.getByText('Workflow evaluation batch')).toBeInTheDocument()
vi.useRealTimers()
})
it('should render time placeholders and hide the value row for empty operators', () => {
const resourceType = 'workflow'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
const timeField = config.fieldOptions.find(field => field.type === 'time')!
let groupId = ''
let itemId = ''
act(() => {
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
const group = useEvaluationStore.getState().resources['workflow:app-2'].conditions[0]
groupId = group.id
itemId = group.items[0].id
store.updateConditionField(resourceType, resourceId, groupId, itemId, timeField.id)
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'before')
})
let rerender: ReturnType<typeof render>['rerender']
act(() => {
({ rerender } = render(<Evaluation resourceType={resourceType} resourceId={resourceId} />))
})
expect(screen.getByText('evaluation.conditions.selectTime')).toBeInTheDocument()
act(() => {
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'is_empty')
rerender(<Evaluation resourceType={resourceType} resourceId={resourceId} />)
})
expect(screen.queryByText('evaluation.conditions.selectTime')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,96 @@
import { getEvaluationMockConfig } from '../mock'
import {
getAllowedOperators,
isCustomMetricConfigured,
requiresConditionValue,
useEvaluationStore,
} from '../store'
describe('evaluation store', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {} })
})
it('should configure a custom metric mapping to a valid state', () => {
const resourceType = 'workflow'
const resourceId = 'app-1'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const initialMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.kind === 'custom-workflow')
expect(initialMetric).toBeDefined()
expect(isCustomMetricConfigured(initialMetric!)).toBe(false)
store.setCustomMetricWorkflow(resourceType, resourceId, initialMetric!.id, config.workflowOptions[0].id)
store.updateCustomMetricMapping(resourceType, resourceId, initialMetric!.id, initialMetric!.customConfig!.mappings[0].id, {
sourceFieldId: config.fieldOptions[0].id,
targetVariableId: config.workflowOptions[0].targetVariables[0].id,
})
const configuredMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
expect(isCustomMetricConfigured(configuredMetric!)).toBe(true)
})
it('should add and remove builtin metrics', () => {
const resourceType = 'workflow'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, config.builtinMetrics[1].id)
const addedMetric = useEvaluationStore.getState().resources['workflow:app-2'].metrics.find(metric => metric.optionId === config.builtinMetrics[1].id)
expect(addedMetric).toBeDefined()
store.removeMetric(resourceType, resourceId, addedMetric!.id)
expect(useEvaluationStore.getState().resources['workflow:app-2'].metrics.some(metric => metric.id === addedMetric!.id)).toBe(false)
})
it('should update condition groups and adapt operators to field types', () => {
const resourceType = 'pipeline'
const resourceId = 'dataset-1'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
const initialGroup = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0]
store.setConditionGroupOperator(resourceType, resourceId, initialGroup.id, 'or')
store.addConditionGroup(resourceType, resourceId)
const booleanField = config.fieldOptions.find(field => field.type === 'boolean')!
const currentItem = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0].items[0]
store.updateConditionField(resourceType, resourceId, initialGroup.id, currentItem.id, booleanField.id)
const updatedGroup = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0]
expect(updatedGroup.logicalOperator).toBe('or')
expect(updatedGroup.items[0].operator).toBe('is')
expect(getAllowedOperators(resourceType, booleanField.id)).toEqual(['is', 'is_not'])
})
it('should support time fields and clear values for empty operators', () => {
const resourceType = 'workflow'
const resourceId = 'app-3'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
const timeField = config.fieldOptions.find(field => field.type === 'time')!
const item = useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].items[0]
store.updateConditionField(resourceType, resourceId, useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].id, item.id, timeField.id)
store.updateConditionOperator(resourceType, resourceId, useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].id, item.id, 'is_empty')
const updatedItem = useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].items[0]
expect(getAllowedOperators(resourceType, timeField.id)).toEqual(['is', 'before', 'after', 'is_empty', 'is_not_empty'])
expect(requiresConditionValue('is_empty')).toBe(false)
expect(updatedItem.value).toBeNull()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
import type {
ComparisonOperator,
EvaluationFieldOption,
EvaluationMockConfig,
EvaluationResourceType,
MetricOption,
} from './types'
const judgeModels = [
{
id: 'gpt-4.1-mini',
label: 'GPT-4.1 mini',
provider: 'OpenAI',
},
{
id: 'claude-3-7-sonnet',
label: 'Claude 3.7 Sonnet',
provider: 'Anthropic',
},
{
id: 'gemini-2.0-flash',
label: 'Gemini 2.0 Flash',
provider: 'Google',
},
]
const builtinMetrics: MetricOption[] = [
{
id: 'answer-correctness',
label: 'Answer Correctness',
description: 'Compares the response with the expected answer and scores factual alignment.',
group: 'quality',
badges: ['LLM', 'Built-in'],
},
{
id: 'faithfulness',
label: 'Faithfulness',
description: 'Checks whether the answer stays grounded in the retrieved evidence.',
group: 'quality',
badges: ['LLM', 'Retrieval'],
},
{
id: 'relevance',
label: 'Relevance',
description: 'Evaluates how directly the answer addresses the original request.',
group: 'quality',
badges: ['LLM'],
},
{
id: 'latency',
label: 'Latency',
description: 'Captures runtime responsiveness for the full execution path.',
group: 'operations',
badges: ['System'],
},
{
id: 'token-usage',
label: 'Token Usage',
description: 'Tracks prompt and completion token consumption for the run.',
group: 'operations',
badges: ['System'],
},
{
id: 'tool-success-rate',
label: 'Tool Success Rate',
description: 'Measures whether each required tool invocation finishes without failure.',
group: 'operations',
badges: ['Workflow'],
},
]
const workflowOptions = [
{
id: 'workflow-precision-review',
label: 'Precision Review Workflow',
description: 'Custom evaluator for nuanced quality review.',
targetVariables: [
{ id: 'query', label: 'query' },
{ id: 'answer', label: 'answer' },
{ id: 'reference', label: 'reference' },
],
},
{
id: 'workflow-risk-review',
label: 'Risk Review Workflow',
description: 'Custom evaluator for policy and escalation checks.',
targetVariables: [
{ id: 'input', label: 'input' },
{ id: 'output', label: 'output' },
],
},
]
const workflowFields: EvaluationFieldOption[] = [
{ id: 'app.input.query', label: 'Query', group: 'App Input', type: 'string' },
{ id: 'app.input.locale', label: 'Locale', group: 'App Input', type: 'enum', options: [{ value: 'en-US', label: 'en-US' }, { value: 'zh-Hans', label: 'zh-Hans' }] },
{ id: 'app.output.answer', label: 'Answer', group: 'App Output', type: 'string' },
{ id: 'app.output.score', label: 'Score', group: 'App Output', type: 'number' },
{ id: 'app.output.published_at', label: 'Publication Date', group: 'App Output', type: 'time' },
{ id: 'system.has_context', label: 'Has Context', group: 'System', type: 'boolean' },
]
const pipelineFields: EvaluationFieldOption[] = [
{ id: 'dataset.input.document_id', label: 'Document ID', group: 'Dataset', type: 'string' },
{ id: 'dataset.input.chunk_count', label: 'Chunk Count', group: 'Dataset', type: 'number' },
{ id: 'dataset.input.updated_at', label: 'Updated At', group: 'Dataset', type: 'time' },
{ id: 'retrieval.output.hit_rate', label: 'Hit Rate', group: 'Retrieval', type: 'number' },
{ id: 'retrieval.output.source', label: 'Source', group: 'Retrieval', type: 'enum', options: [{ value: 'bm25', label: 'BM25' }, { value: 'hybrid', label: 'Hybrid' }] },
{ id: 'pipeline.output.published', label: 'Published', group: 'Output', type: 'boolean' },
]
const snippetFields: EvaluationFieldOption[] = [
{ id: 'snippet.input.blog_url', label: 'Blog URL', group: 'Snippet Input', type: 'string' },
{ id: 'snippet.input.platforms', label: 'Platforms', group: 'Snippet Input', type: 'string' },
{ id: 'snippet.output.content', label: 'Generated Content', group: 'Snippet Output', type: 'string' },
{ id: 'snippet.output.length', label: 'Output Length', group: 'Snippet Output', type: 'number' },
{ id: 'snippet.output.scheduled_at', label: 'Scheduled At', group: 'Snippet Output', type: 'time' },
{ id: 'system.requires_review', label: 'Requires Review', group: 'System', type: 'boolean' },
]
export const getComparisonOperators = (fieldType: EvaluationFieldOption['type']): ComparisonOperator[] => {
if (fieldType === 'number')
return ['is', 'is_not', 'greater_than', 'less_than', 'greater_or_equal', 'less_or_equal', 'is_empty', 'is_not_empty']
if (fieldType === 'time')
return ['is', 'before', 'after', 'is_empty', 'is_not_empty']
if (fieldType === 'boolean' || fieldType === 'enum')
return ['is', 'is_not']
return ['contains', 'not_contains', 'is', 'is_not', 'is_empty', 'is_not_empty']
}
export const getDefaultOperator = (fieldType: EvaluationFieldOption['type']): ComparisonOperator => {
return getComparisonOperators(fieldType)[0]
}
export const getEvaluationMockConfig = (resourceType: EvaluationResourceType): EvaluationMockConfig => {
if (resourceType === 'pipeline') {
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: pipelineFields,
templateFileName: 'pipeline-evaluation-template.csv',
batchRequirements: [
'Include one row per retrieval scenario.',
'Provide the expected source or target chunk for each case.',
'Keep numeric metrics in plain number format.',
],
historySummaryLabel: 'Pipeline evaluation batch',
}
}
if (resourceType === 'snippet') {
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: snippetFields,
templateFileName: 'snippet-evaluation-template.csv',
batchRequirements: [
'Include one row per snippet execution case.',
'Provide the expected final content or acceptance rule.',
'Keep optional fields empty when not used.',
],
historySummaryLabel: 'Snippet evaluation batch',
}
}
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: workflowFields,
templateFileName: 'workflow-evaluation-template.csv',
batchRequirements: [
'Include one row per workflow test case.',
'Provide both user input and expected answer when available.',
'Keep boolean columns as true or false.',
],
historySummaryLabel: 'Workflow evaluation batch',
}
}

View File

@@ -0,0 +1,635 @@
import type {
BatchTestRecord,
ComparisonOperator,
EvaluationFieldOption,
EvaluationMetric,
EvaluationResourceState,
EvaluationResourceType,
JudgmentConditionGroup,
} from './types'
import { create } from 'zustand'
import { getComparisonOperators, getDefaultOperator, getEvaluationMockConfig } from './mock'
type EvaluationStore = {
resources: Record<string, EvaluationResourceState>
ensureResource: (resourceType: EvaluationResourceType, resourceId: string) => void
setJudgeModel: (resourceType: EvaluationResourceType, resourceId: string, judgeModelId: string) => void
addBuiltinMetric: (resourceType: EvaluationResourceType, resourceId: string, optionId: string) => void
addCustomMetric: (resourceType: EvaluationResourceType, resourceId: string) => void
removeMetric: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
setCustomMetricWorkflow: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, workflowId: string) => void
addCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
updateCustomMetricMapping: (
resourceType: EvaluationResourceType,
resourceId: string,
metricId: string,
mappingId: string,
patch: { sourceFieldId?: string | null, targetVariableId?: string | null },
) => void
removeCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, mappingId: string) => void
addConditionGroup: (resourceType: EvaluationResourceType, resourceId: string) => void
removeConditionGroup: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
setConditionGroupOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, logicalOperator: 'and' | 'or') => void
addConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
removeConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string) => void
updateConditionField: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, fieldId: string) => void
updateConditionOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, operator: ComparisonOperator) => void
updateConditionValue: (
resourceType: EvaluationResourceType,
resourceId: string,
groupId: string,
itemId: string,
value: string | number | boolean | null,
) => void
setBatchTab: (resourceType: EvaluationResourceType, resourceId: string, tab: EvaluationResourceState['activeBatchTab']) => void
setUploadedFileName: (resourceType: EvaluationResourceType, resourceId: string, uploadedFileName: string | null) => void
runBatchTest: (resourceType: EvaluationResourceType, resourceId: string) => void
}
const buildResourceKey = (resourceType: EvaluationResourceType, resourceId: string) => `${resourceType}:${resourceId}`
const initialResourceCache: Record<string, EvaluationResourceState> = {}
const createId = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 10)}`
export const conditionOperatorsWithoutValue: ComparisonOperator[] = ['is_empty', 'is_not_empty']
export const requiresConditionValue = (operator: ComparisonOperator) => !conditionOperatorsWithoutValue.includes(operator)
const getConditionValue = (
field: EvaluationFieldOption | undefined,
operator: ComparisonOperator,
previousValue: string | number | boolean | null = null,
) => {
if (!field || !requiresConditionValue(operator))
return null
if (field.type === 'boolean')
return typeof previousValue === 'boolean' ? previousValue : null
if (field.type === 'enum')
return typeof previousValue === 'string' ? previousValue : null
if (field.type === 'number')
return typeof previousValue === 'number' ? previousValue : null
return typeof previousValue === 'string' ? previousValue : null
}
const buildConditionItem = (resourceType: EvaluationResourceType) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions[0]
const operator = field ? getDefaultOperator(field.type) : 'contains'
return {
id: createId('condition'),
fieldId: field?.id ?? null,
operator,
value: getConditionValue(field, operator),
}
}
const buildInitialState = (resourceType: EvaluationResourceType): EvaluationResourceState => {
const config = getEvaluationMockConfig(resourceType)
const defaultMetric = config.builtinMetrics[0]
return {
judgeModelId: null,
metrics: defaultMetric
? [{
id: createId('metric'),
optionId: defaultMetric.id,
kind: 'builtin',
label: defaultMetric.label,
description: defaultMetric.description,
badges: defaultMetric.badges,
}]
: [],
conditions: [{
id: createId('group'),
logicalOperator: 'and',
items: [buildConditionItem(resourceType)],
}],
activeBatchTab: 'input-fields',
uploadedFileName: null,
batchRecords: [],
}
}
const withResourceState = (
resources: EvaluationStore['resources'],
resourceType: EvaluationResourceType,
resourceId: string,
) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
return {
resourceKey,
resource: resources[resourceKey] ?? buildInitialState(resourceType),
}
}
const updateMetric = (
metrics: EvaluationMetric[],
metricId: string,
updater: (metric: EvaluationMetric) => EvaluationMetric,
) => metrics.map(metric => metric.id === metricId ? updater(metric) : metric)
const updateConditionGroup = (
groups: JudgmentConditionGroup[],
groupId: string,
updater: (group: JudgmentConditionGroup) => JudgmentConditionGroup,
) => groups.map(group => group.id === groupId ? updater(group) : group)
export const isCustomMetricConfigured = (metric: EvaluationMetric) => {
if (metric.kind !== 'custom-workflow')
return true
if (!metric.customConfig?.workflowId)
return false
return metric.customConfig.mappings.length > 0
&& metric.customConfig.mappings.every(mapping => !!mapping.sourceFieldId && !!mapping.targetVariableId)
}
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
return !!state.judgeModelId
&& state.metrics.length > 0
&& state.metrics.every(isCustomMetricConfigured)
&& state.conditions.some(group => group.items.length > 0)
}
export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
resources: {},
ensureResource: (resourceType, resourceId) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
if (get().resources[resourceKey])
return
set(state => ({
resources: {
...state.resources,
[resourceKey]: buildInitialState(resourceType),
},
}))
},
setJudgeModel: (resourceType, resourceId, judgeModelId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
judgeModelId,
},
},
}
})
},
addBuiltinMetric: (resourceType, resourceId, optionId) => {
const option = getEvaluationMockConfig(resourceType).builtinMetrics.find(metric => metric.id === optionId)
if (!option)
return
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
if (resource.metrics.some(metric => metric.optionId === optionId && metric.kind === 'builtin'))
return state
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: [
...resource.metrics,
{
id: createId('metric'),
optionId: option.id,
kind: 'builtin',
label: option.label,
description: option.description,
badges: option.badges,
},
],
},
},
}
})
},
addCustomMetric: (resourceType, resourceId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: [
...resource.metrics,
{
id: createId('metric'),
optionId: createId('custom'),
kind: 'custom-workflow',
label: 'Custom Evaluator',
description: 'Map workflow variables to your evaluation inputs.',
badges: ['Workflow'],
customConfig: {
workflowId: null,
mappings: [{
id: createId('mapping'),
sourceFieldId: null,
targetVariableId: null,
}],
},
},
],
},
},
}
})
},
removeMetric: (resourceType, resourceId, metricId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: resource.metrics.filter(metric => metric.id !== metricId),
},
},
}
})
},
setCustomMetricWorkflow: (resourceType, resourceId, metricId, workflowId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
workflowId,
mappings: metric.customConfig.mappings.map(mapping => ({
...mapping,
targetVariableId: null,
})),
}
: metric.customConfig,
})),
},
},
}
})
},
addCustomMetricMapping: (resourceType, resourceId, metricId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: [
...metric.customConfig.mappings,
{
id: createId('mapping'),
sourceFieldId: null,
targetVariableId: null,
},
],
}
: metric.customConfig,
})),
},
},
}
})
},
updateCustomMetricMapping: (resourceType, resourceId, metricId, mappingId, patch) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: metric.customConfig.mappings.map(mapping => mapping.id === mappingId ? { ...mapping, ...patch } : mapping),
}
: metric.customConfig,
})),
},
},
}
})
},
removeCustomMetricMapping: (resourceType, resourceId, metricId, mappingId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: metric.customConfig.mappings.filter(mapping => mapping.id !== mappingId),
}
: metric.customConfig,
})),
},
},
}
})
},
addConditionGroup: (resourceType, resourceId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: [
...resource.conditions,
{
id: createId('group'),
logicalOperator: 'and',
items: [buildConditionItem(resourceType)],
},
],
},
},
}
})
},
removeConditionGroup: (resourceType, resourceId, groupId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: resource.conditions.filter(group => group.id !== groupId),
},
},
}
})
},
setConditionGroupOperator: (resourceType, resourceId, groupId, logicalOperator) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
logicalOperator,
})),
},
},
}
})
},
addConditionItem: (resourceType, resourceId, groupId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: [
...group.items,
buildConditionItem(resourceType),
],
})),
},
},
}
})
},
removeConditionItem: (resourceType, resourceId, groupId, itemId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.filter(item => item.id !== itemId),
})),
},
},
}
})
},
updateConditionField: (resourceType, resourceId, groupId, itemId, fieldId) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map((item) => {
if (item.id !== itemId)
return item
return {
...item,
fieldId,
operator: field ? getDefaultOperator(field.type) : item.operator,
value: getConditionValue(field, field ? getDefaultOperator(field.type) : item.operator),
}
}),
})),
},
},
}
})
},
updateConditionOperator: (resourceType, resourceId, groupId, itemId, operator) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
const fieldOptions = getEvaluationMockConfig(resourceType).fieldOptions
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map((item) => {
if (item.id !== itemId)
return item
const field = fieldOptions.find(option => option.id === item.fieldId)
return {
...item,
operator,
value: getConditionValue(field, operator, item.value),
}
}),
})),
},
},
}
})
},
updateConditionValue: (resourceType, resourceId, groupId, itemId, value) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map(item => item.id === itemId ? { ...item, value } : item),
})),
},
},
}
})
},
setBatchTab: (resourceType, resourceId, tab) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
activeBatchTab: tab,
},
},
}
})
},
setUploadedFileName: (resourceType, resourceId, uploadedFileName) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
uploadedFileName,
},
},
}
})
},
runBatchTest: (resourceType, resourceId) => {
const config = getEvaluationMockConfig(resourceType)
const recordId = createId('batch')
const nextRecord: BatchTestRecord = {
id: recordId,
fileName: get().resources[buildResourceKey(resourceType, resourceId)]?.uploadedFileName ?? config.templateFileName,
status: 'running',
startedAt: new Date().toLocaleTimeString(),
summary: config.historySummaryLabel,
}
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
activeBatchTab: 'history',
batchRecords: [nextRecord, ...resource.batchRecords],
},
},
}
})
window.setTimeout(() => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
batchRecords: resource.batchRecords.map(record => record.id === recordId
? {
...record,
status: resource.metrics.length > 1 ? 'success' : 'failed',
}
: record),
},
},
}
})
}, 1200)
},
}))
export const useEvaluationResource = (resourceType: EvaluationResourceType, resourceId: string) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
return useEvaluationStore(state => state.resources[resourceKey] ?? (initialResourceCache[resourceKey] ??= buildInitialState(resourceType)))
}
export const getAllowedOperators = (resourceType: EvaluationResourceType, fieldId: string | null) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
if (!field)
return ['contains'] as ComparisonOperator[]
return getComparisonOperators(field.type)
}

View File

@@ -0,0 +1,117 @@
export type EvaluationResourceType = 'workflow' | 'pipeline' | 'snippet'
export type MetricKind = 'builtin' | 'custom-workflow'
export type BatchTestTab = 'input-fields' | 'history'
export type FieldType = 'string' | 'number' | 'boolean' | 'enum' | 'time'
export type ComparisonOperator
= | 'contains'
| 'not_contains'
| 'is'
| 'is_not'
| 'is_empty'
| 'is_not_empty'
| 'greater_than'
| 'less_than'
| 'greater_or_equal'
| 'less_or_equal'
| 'before'
| 'after'
export type JudgeModelOption = {
id: string
label: string
provider: string
}
export type MetricOption = {
id: string
label: string
description: string
group: string
badges: string[]
}
export type EvaluationWorkflowOption = {
id: string
label: string
description: string
targetVariables: Array<{
id: string
label: string
}>
}
export type EvaluationFieldOption = {
id: string
label: string
group: string
type: FieldType
options?: Array<{
value: string
label: string
}>
}
export type CustomMetricMapping = {
id: string
sourceFieldId: string | null
targetVariableId: string | null
}
export type CustomMetricConfig = {
workflowId: string | null
mappings: CustomMetricMapping[]
}
export type EvaluationMetric = {
id: string
optionId: string
kind: MetricKind
label: string
description: string
badges: string[]
customConfig?: CustomMetricConfig
}
export type JudgmentConditionItem = {
id: string
fieldId: string | null
operator: ComparisonOperator
value: string | number | boolean | null
}
export type JudgmentConditionGroup = {
id: string
logicalOperator: 'and' | 'or'
items: JudgmentConditionItem[]
}
export type BatchTestRecord = {
id: string
fileName: string
status: 'running' | 'success' | 'failed'
startedAt: string
summary: string
}
export type EvaluationResourceState = {
judgeModelId: string | null
metrics: EvaluationMetric[]
conditions: JudgmentConditionGroup[]
activeBatchTab: BatchTestTab
uploadedFileName: string | null
batchRecords: BatchTestRecord[]
}
export type EvaluationMockConfig = {
judgeModels: JudgeModelOption[]
builtinMetrics: MetricOption[]
workflowOptions: EvaluationWorkflowOption[]
fieldOptions: EvaluationFieldOption[]
templateFileName: string
batchRequirements: string[]
historySummaryLabel: string
}

View File

@@ -105,7 +105,7 @@ const AppNav = () => {
icon={<RiRobot2Line className="h-4 w-4" />}
activeIcon={<RiRobot2Fill className="h-4 w-4" />}
text={t('menus.apps', { ns: 'common' })}
activeSegment={['apps', 'app']}
activeSegment={['apps', 'app', 'snippets']}
link="/apps"
curNav={appDetail}
navigationItems={navItems}

View File

@@ -14,7 +14,7 @@ const HeaderWrapper = ({
children,
}: HeaderWrapperProps) => {
const pathname = usePathname()
const isBordered = ['/apps', '/datasets/create', '/tools'].includes(pathname)
const isBordered = ['/apps', '/snippets', '/datasets/create', '/tools'].includes(pathname)
// Check if the current path is a workflow canvas & fullscreen
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')

View File

@@ -0,0 +1,171 @@
import type { SnippetDetailPayload } from '@/models/snippet'
import { fireEvent, render, screen } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import SnippetPage from '..'
import { useSnippetDetailStore } from '../store'
const mockUseSnippetDetail = vi.fn()
vi.mock('@/service/use-snippets', () => ({
useSnippetDetail: (snippetId: string) => mockUseSnippetDetail(snippetId),
}))
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: () => ({
data: undefined,
}),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: { mobile: 'mobile', desktop: 'desktop' },
}))
vi.mock('@/app/components/workflow', () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-default-context">{children}</div>
),
WorkflowWithInnerContext: ({ children, viewport }: { children: React.ReactNode, viewport?: { zoom?: number } }) => (
<div data-testid="workflow-inner-context">
<span data-testid="workflow-viewport-zoom">{viewport?.zoom ?? 'none'}</span>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-context-provider">{children}</div>
),
}))
vi.mock('@/app/components/app-sidebar', () => ({
default: ({
renderHeader,
renderNavigation,
}: {
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}) => (
<div data-testid="app-sidebar">
<div data-testid="app-sidebar-header">{renderHeader?.('expand')}</div>
<div data-testid="app-sidebar-navigation">{renderNavigation?.('expand')}</div>
</div>
),
}))
vi.mock('@/app/components/app-sidebar/nav-link', () => ({
default: ({ name, onClick }: { name: string, onClick?: () => void }) => (
<button type="button" onClick={onClick}>{name}</button>
),
}))
vi.mock('@/app/components/workflow/panel', () => ({
default: ({ components }: { components?: { left?: React.ReactNode, right?: React.ReactNode } }) => (
<div data-testid="workflow-panel">
<div data-testid="workflow-panel-left">{components?.left}</div>
<div data-testid="workflow-panel-right">{components?.right}</div>
</div>
),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
initialNodes: (nodes: unknown[]) => nodes,
initialEdges: (edges: unknown[]) => edges,
}
})
vi.mock('react-sortablejs', () => ({
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
const mockSnippetDetail: SnippetDetailPayload = {
snippet: {
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'A static snippet mock.',
author: 'Evan',
updatedAt: 'Updated 2h ago',
usage: 'Used 19 times',
icon: '🪄',
iconBackground: '#E0EAFF',
status: 'Draft',
},
graph: {
viewport: { x: 0, y: 0, zoom: 1 },
nodes: [],
edges: [],
},
inputFields: [
{
type: PipelineInputVarType.textInput,
label: 'Blog URL',
variable: 'blog_url',
required: true,
options: [],
placeholder: 'Paste a source article URL',
max_length: 256,
},
],
uiMeta: {
inputFieldCount: 1,
checklistCount: 2,
autoSavedAt: 'Auto-saved · a few seconds ago',
},
}
describe('SnippetPage', () => {
beforeEach(() => {
vi.clearAllMocks()
useSnippetDetailStore.getState().reset()
mockUseSnippetDetail.mockReturnValue({
data: mockSnippetDetail,
isLoading: false,
})
})
it('should render the snippet detail shell', () => {
render(<SnippetPage snippetId="snippet-1" />)
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
expect(screen.getByText('A static snippet mock.')).toBeInTheDocument()
expect(screen.getByTestId('app-sidebar')).toBeInTheDocument()
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument()
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
expect(screen.getByTestId('workflow-viewport-zoom').textContent).toBe('1')
})
it('should open the input field panel and editor', () => {
render(<SnippetPage snippetId="snippet-1" />)
fireEvent.click(screen.getAllByRole('button', { name: /snippet\.inputFieldButton/i })[0])
expect(screen.getAllByText('snippet.panelTitle').length).toBeGreaterThan(0)
fireEvent.click(screen.getAllByRole('button', { name: /datasetPipeline\.inputFieldPanel\.addInputField/i })[0])
expect(screen.getAllByText('datasetPipeline.inputFieldPanel.addInputField').length).toBeGreaterThan(1)
})
it('should toggle the publish menu', () => {
render(<SnippetPage snippetId="snippet-1" />)
fireEvent.click(screen.getByRole('button', { name: /snippet\.publishButton/i }))
expect(screen.getByText('snippet.publishMenuCurrentDraft')).toBeInTheDocument()
})
it('should render a controlled not found state', () => {
mockUseSnippetDetail.mockReturnValue({
data: null,
isLoading: false,
})
render(<SnippetPage snippetId="missing-snippet" />)
expect(screen.getByText('snippet.notFoundTitle')).toBeInTheDocument()
expect(screen.getByText('snippet.notFoundDescription')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,54 @@
'use client'
import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
import type { SnippetInputField } from '@/models/snippet'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import InputFieldForm from '@/app/components/rag-pipeline/components/panel/input-field/editor/form'
import { convertFormDataToINputField, convertToInputFieldFormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/utils'
type SnippetInputFieldEditorProps = {
field?: SnippetInputField | null
onClose: () => void
onSubmit: (field: SnippetInputField) => void
}
const SnippetInputFieldEditor = ({
field,
onClose,
onSubmit,
}: SnippetInputFieldEditorProps) => {
const { t } = useTranslation()
const initialData = useMemo(() => {
return convertToInputFieldFormData(field || undefined)
}, [field])
const handleSubmit = useCallback((value: FormData) => {
onSubmit(convertFormDataToINputField(value))
}, [onSubmit])
return (
<div className="relative mr-1 flex h-fit max-h-full w-[min(400px,calc(100vw-24px))] flex-col overflow-y-auto rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9">
<div className="flex items-center pb-1 pl-4 pr-11 pt-3.5 text-text-primary system-xl-semibold">
{field ? t('inputFieldPanel.editInputField', { ns: 'datasetPipeline' }) : t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
</div>
<button
type="button"
className="absolute right-2.5 top-2.5 flex h-8 w-8 items-center justify-center"
onClick={onClose}
>
<span aria-hidden className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</button>
<InputFieldForm
initialData={initialData}
supportFile
onCancel={onClose}
onSubmit={handleSubmit}
isEditMode={!!field}
/>
</div>
)
}
export default SnippetInputFieldEditor

View File

@@ -0,0 +1,119 @@
'use client'
import type { SortableItem } from '@/app/components/rag-pipeline/components/panel/input-field/field-list/types'
import type { SnippetInputField } from '@/models/snippet'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import FieldListContainer from '@/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container'
type SnippetInputFieldPanelProps = {
fields: SnippetInputField[]
onClose: () => void
onAdd: () => void
onEdit: (field: SnippetInputField) => void
onRemove: (index: number) => void
onPrimarySortChange: (fields: SnippetInputField[]) => void
onSecondarySortChange: (fields: SnippetInputField[]) => void
}
const toInputFields = (list: SortableItem[]) => {
return list.map((item) => {
const { id: _id, chosen: _chosen, selected: _selected, ...field } = item
return field
})
}
const SnippetInputFieldPanel = ({
fields,
onClose,
onAdd,
onEdit,
onRemove,
onPrimarySortChange,
onSecondarySortChange,
}: SnippetInputFieldPanelProps) => {
const { t } = useTranslation('snippet')
const primaryFields = fields.slice(0, 2)
const secondaryFields = fields.slice(2)
const handlePrimaryRemove = useCallback((index: number) => {
onRemove(index)
}, [onRemove])
const handleSecondaryRemove = useCallback((index: number) => {
onRemove(index + primaryFields.length)
}, [onRemove, primaryFields.length])
const handlePrimaryEdit = useCallback((id: string) => {
const field = primaryFields.find(item => item.variable === id)
if (field)
onEdit(field)
}, [onEdit, primaryFields])
const handleSecondaryEdit = useCallback((id: string) => {
const field = secondaryFields.find(item => item.variable === id)
if (field)
onEdit(field)
}, [onEdit, secondaryFields])
return (
<div className="mr-1 flex h-full w-[min(400px,calc(100vw-24px))] flex-col rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
<div className="flex items-start justify-between gap-3 px-4 pb-2 pt-4">
<div className="min-w-0">
<div className="text-text-primary system-xl-semibold">
{t('panelTitle')}
</div>
<div className="pt-1 text-text-tertiary system-sm-regular">
{t('panelDescription')}
</div>
</div>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover"
onClick={onClose}
>
<span aria-hidden className="i-ri-close-line h-4 w-4" />
</button>
</div>
<div className="px-4 pb-2">
<Button variant="secondary" size="small" className="w-full justify-center gap-1" onClick={onAdd}>
<span aria-hidden className="i-ri-add-line h-4 w-4" />
{t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
</Button>
</div>
<div className="flex grow flex-col overflow-y-auto">
<div className="px-4 pb-1 pt-2 text-text-secondary system-xs-semibold-uppercase">
{t('panelPrimaryGroup')}
</div>
<FieldListContainer
className="flex flex-col gap-y-1 px-4 pb-2"
inputFields={primaryFields}
onListSortChange={list => onPrimarySortChange(toInputFields(list))}
onRemoveField={handlePrimaryRemove}
onEditField={handlePrimaryEdit}
/>
<div className="px-4 py-2">
<Divider type="horizontal" className="bg-divider-subtle" />
</div>
<div className="px-4 pb-1 text-text-secondary system-xs-semibold-uppercase">
{t('panelSecondaryGroup')}
</div>
<FieldListContainer
className="flex flex-col gap-y-1 px-4 pb-4"
inputFields={secondaryFields}
onListSortChange={list => onSecondarySortChange(toInputFields(list))}
onRemoveField={handleSecondaryRemove}
onEditField={handleSecondaryEdit}
/>
</div>
</div>
)
}
export default memo(SnippetInputFieldPanel)

View File

@@ -0,0 +1,29 @@
'use client'
import type { SnippetDetailUIModel } from '@/models/snippet'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
const PublishMenu = ({
uiMeta,
}: {
uiMeta: SnippetDetailUIModel
}) => {
const { t } = useTranslation('snippet')
return (
<div className="w-80 rounded-2xl border border-components-panel-border bg-components-panel-bg p-4 shadow-[0px_20px_24px_-4px_rgba(9,9,11,0.08),0px_8px_8px_-4px_rgba(9,9,11,0.03)]">
<div className="text-text-tertiary system-xs-semibold-uppercase">
{t('publishMenuCurrentDraft')}
</div>
<div className="pt-1 text-text-secondary system-sm-medium">
{uiMeta.autoSavedAt}
</div>
<Button variant="primary" size="small" className="mt-4 w-full justify-center">
{t('publishButton')}
</Button>
</div>
)
}
export default PublishMenu

View File

@@ -0,0 +1,106 @@
'use client'
import type { SnippetDetailUIModel, SnippetInputField } from '@/models/snippet'
import SnippetInputFieldEditor from './input-field-editor'
import SnippetInputFieldPanel from './panel'
import PublishMenu from './publish-menu'
import SnippetHeader from './snippet-header'
import SnippetWorkflowPanel from './workflow-panel'
type SnippetChildrenProps = {
fields: SnippetInputField[]
uiMeta: SnippetDetailUIModel
editingField: SnippetInputField | null
isEditorOpen: boolean
isInputPanelOpen: boolean
isPublishMenuOpen: boolean
onToggleInputPanel: () => void
onTogglePublishMenu: () => void
onCloseInputPanel: () => void
onOpenEditor: (field?: SnippetInputField | null) => void
onCloseEditor: () => void
onSubmitField: (field: SnippetInputField) => void
onRemoveField: (index: number) => void
onPrimarySortChange: (fields: SnippetInputField[]) => void
onSecondarySortChange: (fields: SnippetInputField[]) => void
}
const SnippetChildren = ({
fields,
uiMeta,
editingField,
isEditorOpen,
isInputPanelOpen,
isPublishMenuOpen,
onToggleInputPanel,
onTogglePublishMenu,
onCloseInputPanel,
onOpenEditor,
onCloseEditor,
onSubmitField,
onRemoveField,
onPrimarySortChange,
onSecondarySortChange,
}: SnippetChildrenProps) => {
return (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-background-body to-transparent" />
<SnippetHeader
inputFieldCount={fields.length}
onToggleInputPanel={onToggleInputPanel}
onTogglePublishMenu={onTogglePublishMenu}
/>
<SnippetWorkflowPanel
fields={fields}
editingField={editingField}
isEditorOpen={isEditorOpen}
isInputPanelOpen={isInputPanelOpen}
onCloseInputPanel={onCloseInputPanel}
onOpenEditor={onOpenEditor}
onCloseEditor={onCloseEditor}
onSubmitField={onSubmitField}
onRemoveField={onRemoveField}
onPrimarySortChange={onPrimarySortChange}
onSecondarySortChange={onSecondarySortChange}
/>
{isPublishMenuOpen && (
<div className="absolute right-3 top-14 z-20">
<PublishMenu uiMeta={uiMeta} />
</div>
)}
{isInputPanelOpen && (
<div className="pointer-events-none absolute inset-y-3 right-3 z-30 flex justify-end">
<div className="pointer-events-auto h-full xl:hidden">
<SnippetInputFieldPanel
fields={fields}
onClose={onCloseInputPanel}
onAdd={() => onOpenEditor()}
onEdit={onOpenEditor}
onRemove={onRemoveField}
onPrimarySortChange={onPrimarySortChange}
onSecondarySortChange={onSecondarySortChange}
/>
</div>
</div>
)}
{isEditorOpen && (
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center bg-black/10 px-3 xl:hidden">
<div className="pointer-events-auto w-full max-w-md">
<SnippetInputFieldEditor
field={editingField}
onClose={onCloseEditor}
onSubmit={onSubmitField}
/>
</div>
</div>
)}
</>
)
}
export default SnippetChildren

View File

@@ -0,0 +1,61 @@
'use client'
import { useTranslation } from 'react-i18next'
type SnippetHeaderProps = {
inputFieldCount: number
onToggleInputPanel: () => void
onTogglePublishMenu: () => void
}
const SnippetHeader = ({
inputFieldCount,
onToggleInputPanel,
onTogglePublishMenu,
}: SnippetHeaderProps) => {
const { t } = useTranslation('snippet')
return (
<div className="absolute right-3 top-3 z-20 flex flex-wrap items-center justify-end gap-2">
<button
type="button"
className="flex items-center gap-2 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-text-secondary shadow-xs backdrop-blur"
onClick={onToggleInputPanel}
>
<span className="text-[13px] font-medium leading-4">{t('inputFieldButton')}</span>
<span className="rounded-md border border-divider-deep px-1.5 py-0.5 text-[10px] font-medium leading-3 text-text-tertiary">
{inputFieldCount}
</span>
</button>
<button
type="button"
className="flex items-center gap-2 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-text-accent shadow-xs backdrop-blur"
>
<span aria-hidden className="i-ri-play-mini-fill h-4 w-4" />
<span className="text-[13px] font-medium leading-4">{t('testRunButton')}</span>
<span className="rounded-md bg-state-accent-active px-1.5 py-0.5 text-[10px] font-semibold leading-3 text-text-accent">R</span>
</button>
<div className="relative">
<button
type="button"
className="flex items-center gap-1 rounded-lg bg-components-button-primary-bg px-3 py-2 text-white shadow-[0px_2px_2px_-1px_rgba(0,0,0,0.12),0px_1px_1px_-1px_rgba(0,0,0,0.12),0px_0px_0px_0.5px_rgba(9,9,11,0.05)]"
onClick={onTogglePublishMenu}
>
<span className="text-[13px] font-medium leading-4">{t('publishButton')}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4" />
</button>
</div>
<button
type="button"
className="flex h-9 w-9 items-center justify-center rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg text-text-tertiary shadow-xs"
>
<span aria-hidden className="i-ri-more-2-line h-4 w-4" />
</button>
</div>
)
}
export default SnippetHeader

View File

@@ -0,0 +1,198 @@
'use client'
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
import type { WorkflowProps } from '@/app/components/workflow'
import type { SnippetDetailPayload, SnippetInputField, SnippetSection } from '@/models/snippet'
import {
RiFlaskFill,
RiFlaskLine,
RiGitBranchFill,
RiGitBranchLine,
} from '@remixicon/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import AppSideBar from '@/app/components/app-sidebar'
import NavLink from '@/app/components/app-sidebar/nav-link'
import SnippetInfo from '@/app/components/app-sidebar/snippet-info'
import { useStore as useAppStore } from '@/app/components/app/store'
import Toast from '@/app/components/base/toast'
import Evaluation from '@/app/components/evaluation'
import { WorkflowWithInnerContext } from '@/app/components/workflow'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useSnippetDetailStore } from '../store'
import SnippetChildren from './snippet-children'
type SnippetMainProps = {
payload: SnippetDetailPayload
snippetId: string
section: SnippetSection
} & Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
const ORCHESTRATE_ICONS: { normal: NavIcon, selected: NavIcon } = {
normal: RiGitBranchLine,
selected: RiGitBranchFill,
}
const EVALUATION_ICONS: { normal: NavIcon, selected: NavIcon } = {
normal: RiFlaskLine,
selected: RiFlaskFill,
}
const SnippetMain = ({
payload,
snippetId,
section,
nodes,
edges,
viewport,
}: SnippetMainProps) => {
const { t } = useTranslation('snippet')
const { graph, snippet, uiMeta } = payload
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const [fields, setFields] = useState<SnippetInputField[]>(payload.inputFields)
const setAppSidebarExpand = useAppStore(state => state.setAppSidebarExpand)
const {
editingField,
isEditorOpen,
isInputPanelOpen,
isPublishMenuOpen,
closeEditor,
openEditor,
reset,
setInputPanelOpen,
toggleInputPanel,
togglePublishMenu,
} = useSnippetDetailStore(useShallow(state => ({
editingField: state.editingField,
isEditorOpen: state.isEditorOpen,
isInputPanelOpen: state.isInputPanelOpen,
isPublishMenuOpen: state.isPublishMenuOpen,
closeEditor: state.closeEditor,
openEditor: state.openEditor,
reset: state.reset,
setInputPanelOpen: state.setInputPanelOpen,
toggleInputPanel: state.toggleInputPanel,
togglePublishMenu: state.togglePublishMenu,
})))
useEffect(() => {
reset()
}, [reset, snippetId])
useEffect(() => {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode)
}, [isMobile, setAppSidebarExpand])
const primaryFields = useMemo(() => fields.slice(0, 2), [fields])
const secondaryFields = useMemo(() => fields.slice(2), [fields])
const handlePrimarySortChange = (newFields: SnippetInputField[]) => {
setFields([...newFields, ...secondaryFields])
}
const handleSecondarySortChange = (newFields: SnippetInputField[]) => {
setFields([...primaryFields, ...newFields])
}
const handleRemoveField = (index: number) => {
setFields(current => current.filter((_, currentIndex) => currentIndex !== index))
}
const handleSubmitField = (field: SnippetInputField) => {
const originalVariable = editingField?.variable
const duplicated = fields.some(item => item.variable === field.variable && item.variable !== originalVariable)
if (duplicated) {
Toast.notify({
type: 'error',
message: t('inputFieldPanel.error.variableDuplicate', { ns: 'datasetPipeline' }),
})
return
}
if (originalVariable)
setFields(current => current.map(item => item.variable === originalVariable ? field : item))
else
setFields(current => [...current, field])
closeEditor()
}
const handleToggleInputPanel = () => {
if (isInputPanelOpen)
closeEditor()
toggleInputPanel()
}
const handleCloseInputPanel = () => {
closeEditor()
setInputPanelOpen(false)
}
return (
<div className="relative flex h-full overflow-hidden bg-background-body">
<AppSideBar
navigation={[]}
renderHeader={mode => <SnippetInfo expand={mode === 'expand'} snippet={snippet} />}
renderNavigation={mode => (
<>
<NavLink
mode={mode}
name={t('sectionOrchestrate')}
iconMap={ORCHESTRATE_ICONS}
href={`/snippets/${snippetId}/orchestrate`}
active={section === 'orchestrate'}
/>
<NavLink
mode={mode}
name={t('sectionEvaluation')}
iconMap={EVALUATION_ICONS}
href={`/snippets/${snippetId}/evaluation`}
active={section === 'evaluation'}
/>
</>
)}
/>
<div className="relative min-h-0 min-w-0 grow overflow-hidden">
<div className="absolute inset-0 min-h-0 min-w-0 overflow-hidden">
{section === 'evaluation'
? (
<Evaluation resourceType="snippet" resourceId={snippetId} />
)
: (
<WorkflowWithInnerContext
nodes={nodes}
edges={edges}
viewport={viewport ?? graph.viewport}
>
<SnippetChildren
fields={fields}
uiMeta={uiMeta}
editingField={editingField}
isEditorOpen={isEditorOpen}
isInputPanelOpen={isInputPanelOpen}
isPublishMenuOpen={isPublishMenuOpen}
onToggleInputPanel={handleToggleInputPanel}
onTogglePublishMenu={togglePublishMenu}
onCloseInputPanel={handleCloseInputPanel}
onOpenEditor={openEditor}
onCloseEditor={closeEditor}
onSubmitField={handleSubmitField}
onRemoveField={handleRemoveField}
onPrimarySortChange={handlePrimarySortChange}
onSecondarySortChange={handleSecondarySortChange}
/>
</WorkflowWithInnerContext>
)}
</div>
</div>
</div>
)
}
export default SnippetMain

View File

@@ -0,0 +1,111 @@
'use client'
import type { PanelProps } from '@/app/components/workflow/panel'
import type { SnippetInputField } from '@/models/snippet'
import { memo, useMemo } from 'react'
import Panel from '@/app/components/workflow/panel'
import SnippetInputFieldEditor from './input-field-editor'
import SnippetInputFieldPanel from './panel'
type SnippetWorkflowPanelProps = {
fields: SnippetInputField[]
editingField: SnippetInputField | null
isEditorOpen: boolean
isInputPanelOpen: boolean
onCloseInputPanel: () => void
onOpenEditor: (field?: SnippetInputField | null) => void
onCloseEditor: () => void
onSubmitField: (field: SnippetInputField) => void
onRemoveField: (index: number) => void
onPrimarySortChange: (fields: SnippetInputField[]) => void
onSecondarySortChange: (fields: SnippetInputField[]) => void
}
const SnippetPanelOnLeft = ({
fields,
editingField,
isEditorOpen,
isInputPanelOpen,
onCloseInputPanel,
onOpenEditor,
onCloseEditor,
onSubmitField,
onRemoveField,
onPrimarySortChange,
onSecondarySortChange,
}: SnippetWorkflowPanelProps) => {
return (
<div className="hidden xl:flex">
{isEditorOpen && (
<SnippetInputFieldEditor
field={editingField}
onClose={onCloseEditor}
onSubmit={onSubmitField}
/>
)}
{isInputPanelOpen && (
<SnippetInputFieldPanel
fields={fields}
onClose={onCloseInputPanel}
onAdd={() => onOpenEditor()}
onEdit={onOpenEditor}
onRemove={onRemoveField}
onPrimarySortChange={onPrimarySortChange}
onSecondarySortChange={onSecondarySortChange}
/>
)}
</div>
)
}
const SnippetWorkflowPanel = ({
fields,
editingField,
isEditorOpen,
isInputPanelOpen,
onCloseInputPanel,
onOpenEditor,
onCloseEditor,
onSubmitField,
onRemoveField,
onPrimarySortChange,
onSecondarySortChange,
}: SnippetWorkflowPanelProps) => {
const panelProps: PanelProps = useMemo(() => {
return {
components: {
left: (
<SnippetPanelOnLeft
fields={fields}
editingField={editingField}
isEditorOpen={isEditorOpen}
isInputPanelOpen={isInputPanelOpen}
onCloseInputPanel={onCloseInputPanel}
onOpenEditor={onOpenEditor}
onCloseEditor={onCloseEditor}
onSubmitField={onSubmitField}
onRemoveField={onRemoveField}
onPrimarySortChange={onPrimarySortChange}
onSecondarySortChange={onSecondarySortChange}
/>
),
},
}
}, [
editingField,
fields,
isEditorOpen,
isInputPanelOpen,
onCloseEditor,
onCloseInputPanel,
onOpenEditor,
onPrimarySortChange,
onRemoveField,
onSecondarySortChange,
onSubmitField,
])
return <Panel {...panelProps} />
}
export default memo(SnippetWorkflowPanel)

View File

@@ -0,0 +1,5 @@
import { useSnippetDetail } from '@/service/use-snippets'
export const useSnippetInit = (snippetId: string) => {
return useSnippetDetail(snippetId)
}

View File

@@ -0,0 +1,86 @@
'use client'
import type { SnippetSection } from '@/models/snippet'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import WorkflowWithDefaultContext from '@/app/components/workflow'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import {
initialEdges,
initialNodes,
} from '@/app/components/workflow/utils'
import SnippetMain from './components/snippet-main'
import { useSnippetInit } from './hooks/use-snippet-init'
type SnippetPageProps = {
snippetId: string
section?: SnippetSection
}
const SnippetPage = ({
snippetId,
section = 'orchestrate',
}: SnippetPageProps) => {
const { t } = useTranslation('snippet')
const { data, isLoading } = useSnippetInit(snippetId)
const nodesData = useMemo(() => {
if (!data)
return []
return initialNodes(data.graph.nodes, data.graph.edges)
}, [data])
const edgesData = useMemo(() => {
if (!data)
return []
return initialEdges(data.graph.edges, data.graph.nodes)
}, [data])
if (isLoading) {
return (
<div className="flex h-full items-center justify-center bg-background-body">
<Loading />
</div>
)
}
if (!data) {
return (
<div className="flex h-full items-center justify-center bg-background-body px-6">
<div className="w-full max-w-md rounded-2xl border border-divider-subtle bg-components-card-bg p-8 text-center shadow-sm">
<div className="text-3xl font-semibold text-text-primary">404</div>
<div className="pt-3 text-text-primary system-md-semibold">{t('notFoundTitle')}</div>
<div className="pt-2 text-text-tertiary system-sm-regular">{t('notFoundDescription')}</div>
</div>
</div>
)
}
return (
<WorkflowWithDefaultContext
edges={edgesData}
nodes={nodesData}
>
<SnippetMain
key={snippetId}
snippetId={snippetId}
section={section}
payload={data}
nodes={nodesData}
edges={edgesData}
viewport={data.graph.viewport}
/>
</WorkflowWithDefaultContext>
)
}
const SnippetPageWrapper = (props: SnippetPageProps) => {
return (
<WorkflowContextProvider>
<SnippetPage {...props} />
</WorkflowContextProvider>
)
}
export default SnippetPageWrapper

View File

@@ -0,0 +1,44 @@
'use client'
import type { SnippetInputField, SnippetSection } from '@/models/snippet'
import { create } from 'zustand'
type SnippetDetailUIState = {
activeSection: SnippetSection
isInputPanelOpen: boolean
isPublishMenuOpen: boolean
isPreviewMode: boolean
isEditorOpen: boolean
editingField: SnippetInputField | null
setActiveSection: (section: SnippetSection) => void
setInputPanelOpen: (value: boolean) => void
toggleInputPanel: () => void
setPublishMenuOpen: (value: boolean) => void
togglePublishMenu: () => void
setPreviewMode: (value: boolean) => void
openEditor: (field?: SnippetInputField | null) => void
closeEditor: () => void
reset: () => void
}
const initialState = {
activeSection: 'orchestrate' as SnippetSection,
isInputPanelOpen: false,
isPublishMenuOpen: false,
isPreviewMode: false,
editingField: null,
isEditorOpen: false,
}
export const useSnippetDetailStore = create<SnippetDetailUIState>(set => ({
...initialState,
setActiveSection: activeSection => set({ activeSection }),
setInputPanelOpen: isInputPanelOpen => set({ isInputPanelOpen }),
toggleInputPanel: () => set(state => ({ isInputPanelOpen: !state.isInputPanelOpen, isPublishMenuOpen: false })),
setPublishMenuOpen: isPublishMenuOpen => set({ isPublishMenuOpen }),
togglePublishMenu: () => set(state => ({ isPublishMenuOpen: !state.isPublishMenuOpen })),
setPreviewMode: isPreviewMode => set({ isPreviewMode }),
openEditor: (editingField = null) => set({ editingField, isEditorOpen: true, isInputPanelOpen: true }),
closeEditor: () => set({ editingField: null, isEditorOpen: false }),
reset: () => set(initialState),
}))

View File

@@ -71,6 +71,10 @@ export const useTabs = ({
name: t('tabs.start', { ns: 'workflow' }),
show: shouldShowStartTab,
disabled: shouldDisableStartTab,
}, {
key: TabsEnum.Snippets,
name: t('tabs.snippets', { ns: 'workflow' }),
show: true,
}]
return tabConfigs.filter(tab => tab.show)
@@ -100,6 +104,7 @@ export const useTabs = ({
preferredOrder.push(TabsEnum.Sources)
if (!noStart)
preferredOrder.push(TabsEnum.Start)
preferredOrder.push(TabsEnum.Snippets)
for (const tabKey of preferredOrder) {
const validKey = getValidTabKey(tabKey)

View File

@@ -15,6 +15,7 @@ import type {
import {
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
@@ -32,6 +33,7 @@ import SearchBox from '@/app/components/plugins/marketplace/search-box'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { BlockEnum, isTriggerNode } from '../types'
import { useTabs } from './hooks'
import Snippets from './snippets'
import Tabs from './tabs'
import { TabsEnum } from './types'
@@ -88,6 +90,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
const { t } = useTranslation()
const nodes = useNodes()
const [searchText, setSearchText] = useState('')
const [snippetsLoading, setSnippetsLoading] = useState(() => Boolean(openFromProps) && defaultActiveTab === TabsEnum.Snippets)
const [tags, setTags] = useState<string[]>([])
const [localOpen, setLocalOpen] = useState(false)
// Exclude nodes explicitly ignored (such as the node currently being edited) when checking canvas state.
@@ -119,28 +122,6 @@ const NodeSelector: FC<NodeSelectorProps> = ({
// Default rule: user input option is only available when no Start node nor Trigger node exists on canvas.
const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
const open = openFromProps === undefined ? localOpen : openFromProps
const handleOpenChange = useCallback((newOpen: boolean) => {
setLocalOpen(newOpen)
if (!newOpen)
setSearchText('')
if (onOpenChange)
onOpenChange(newOpen)
}, [onOpenChange])
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
if (disabled)
return
e.stopPropagation()
handleOpenChange(!open)
}, [handleOpenChange, open, disabled])
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
handleOpenChange(false)
onSelect(type, pluginDefaultValue)
}, [handleOpenChange, onSelect])
const {
activeTab,
setActiveTab,
@@ -154,10 +135,51 @@ const NodeSelector: FC<NodeSelectorProps> = ({
hasUserInputNode,
forceEnableStartTab,
})
const open = openFromProps === undefined ? localOpen : openFromProps
const handleOpenChange = useCallback((newOpen: boolean) => {
setLocalOpen(newOpen)
if (!newOpen) {
setSearchText('')
setSnippetsLoading(false)
}
else if (activeTab === TabsEnum.Snippets) {
setSnippetsLoading(true)
}
if (onOpenChange)
onOpenChange(newOpen)
}, [activeTab, onOpenChange])
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
if (disabled)
return
e.stopPropagation()
handleOpenChange(!open)
}, [handleOpenChange, open, disabled])
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
handleOpenChange(false)
onSelect(type, pluginDefaultValue)
}, [handleOpenChange, onSelect])
const handleActiveTabChange = useCallback((newActiveTab: TabsEnum) => {
setActiveTab(newActiveTab)
}, [setActiveTab])
if (open && newActiveTab === TabsEnum.Snippets)
setSnippetsLoading(true)
}, [open, setActiveTab])
useEffect(() => {
if (!snippetsLoading)
return
const timer = window.setTimeout(() => {
setSnippetsLoading(false)
}, 200)
return () => {
window.clearTimeout(timer)
}
}, [snippetsLoading])
const searchPlaceholder = useMemo(() => {
if (activeTab === TabsEnum.Start)
@@ -171,6 +193,8 @@ const NodeSelector: FC<NodeSelectorProps> = ({
if (activeTab === TabsEnum.Sources)
return t('tabs.searchDataSource', { ns: 'workflow' })
if (activeTab === TabsEnum.Snippets)
return t('tabs.searchSnippets', { ns: 'workflow' })
return ''
}, [activeTab, t])
@@ -203,7 +227,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1000]">
<PortalToFollowElemContent className="z-[1002]">
<div className={`rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg ${popupClassName}`}>
<Tabs
tabs={tabs}
@@ -257,6 +281,17 @@ const NodeSelector: FC<NodeSelectorProps> = ({
inputClassName="grow"
/>
)}
{activeTab === TabsEnum.Snippets && (
<Input
showLeftIcon
showClearIcon
autoFocus
value={searchText}
placeholder={searchPlaceholder}
onChange={e => setSearchText(e.target.value)}
onClear={() => setSearchText('')}
/>
)}
</div>
)}
onSelect={handleSelect}
@@ -268,6 +303,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
noTools={noTools}
onTagsChange={setTags}
forceShowStartContent={forceShowStartContent}
snippetsElem={<Snippets loading={snippetsLoading} searchText={searchText} />}
/>
</div>
</PortalToFollowElemContent>

View File

@@ -0,0 +1,247 @@
import type { ReactNode } from 'react'
import {
memo,
useDeferredValue,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import {
SearchMenu,
} from '@/app/components/base/icons/src/vender/line/others'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/app/components/base/ui/tooltip'
import { cn } from '@/utils/classnames'
import BlockIcon from '../block-icon'
import { BlockEnum } from '../types'
type SnippetsProps = {
loading?: boolean
searchText: string
}
type StaticSnippet = {
id: string
badge: string
badgeClassName: string
title: string
description: string
author?: string
relatedBlocks?: BlockEnum[]
}
const STATIC_SNIPPETS: StaticSnippet[] = [
{
id: 'customer-review',
badge: 'CR',
title: 'Customer Review',
description: 'Customer Review Description',
author: 'Evan',
relatedBlocks: [
BlockEnum.LLM,
BlockEnum.Code,
BlockEnum.KnowledgeRetrieval,
BlockEnum.QuestionClassifier,
BlockEnum.IfElse,
],
badgeClassName: 'bg-gradient-to-br from-orange-500 to-rose-500',
},
] as const
const LoadingSkeleton = () => {
return (
<div className="relative overflow-hidden">
<div className="p-1">
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => (
<div
key={key}
className={cn(
'flex items-center gap-1 px-3 py-1 opacity-20',
index === 3 && 'opacity-10',
)}
>
<div className="my-1 h-6 w-6 shrink-0 rounded-lg border-[0.5px] border-effects-icon-border bg-text-quaternary" />
<div className="min-w-0 flex-1 px-1 py-1">
<div className="h-2 w-[200px] rounded-[2px] bg-text-quaternary" />
</div>
</div>
))}
</div>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-components-panel-bg-transparent to-background-default-subtle" />
</div>
)
}
const SnippetBadge = ({
badge,
badgeClassName,
}: Pick<StaticSnippet, 'badge' | 'badgeClassName'>) => {
return (
<div
aria-hidden="true"
className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-[9px] font-semibold uppercase text-white shadow-[0px_3px_10px_-2px_rgba(9,9,11,0.08),0px_2px_4px_-2px_rgba(9,9,11,0.06)]',
badgeClassName,
)}
>
{badge}
</div>
)
}
const SnippetDetailCard = ({
author,
description,
relatedBlocks = [],
title,
triggerBadge,
}: {
author?: string
description?: string
relatedBlocks?: BlockEnum[]
title: string
triggerBadge: ReactNode
}) => {
return (
<div className="w-[224px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-3 pb-4 pt-3 shadow-lg backdrop-blur-[5px]">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
{triggerBadge}
<div className="text-text-primary system-md-medium">{title}</div>
</div>
{!!description && (
<div className="w-[200px] text-text-secondary system-xs-regular">
{description}
</div>
)}
{!!relatedBlocks.length && (
<div className="flex items-center gap-0.5 pt-1">
{relatedBlocks.map(block => (
<BlockIcon
key={block}
type={block}
size="sm"
/>
))}
</div>
)}
</div>
{!!author && (
<div className="pt-3 text-text-tertiary system-xs-regular">
{author}
</div>
)}
</div>
)
}
const Snippets = ({
loading = false,
searchText,
}: SnippetsProps) => {
const { t } = useTranslation()
const deferredSearchText = useDeferredValue(searchText)
const [hoveredSnippetId, setHoveredSnippetId] = useState<string | null>(null)
const snippets = useMemo(() => {
return STATIC_SNIPPETS.map(item => ({
...item,
}))
}, [])
const filteredSnippets = useMemo(() => {
const normalizedSearch = deferredSearchText.trim().toLowerCase()
if (!normalizedSearch)
return snippets
return snippets.filter(item => item.title.toLowerCase().includes(normalizedSearch))
}, [deferredSearchText, snippets])
if (loading)
return <LoadingSkeleton />
if (!filteredSnippets.length) {
return (
<div className="flex min-h-[480px] flex-col items-center justify-center gap-2 px-4">
<SearchMenu className="h-8 w-8 text-text-tertiary" />
<div className="text-text-secondary system-sm-regular">
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
</div>
<Button
variant="secondary-accent"
size="small"
onClick={(e) => {
e.preventDefault()
}}
>
{t('tabs.createSnippet', { ns: 'workflow' })}
</Button>
</div>
)
}
return (
<div className="max-h-[480px] max-w-[500px] overflow-y-auto p-1">
{filteredSnippets.map((item) => {
const badge = (
<SnippetBadge
badge={item.badge}
badgeClassName={item.badgeClassName}
/>
)
const row = (
<div
className={cn(
'flex h-8 items-center gap-2 rounded-lg px-3',
hoveredSnippetId === item.id && 'bg-background-default-hover',
)}
onMouseEnter={() => setHoveredSnippetId(item.id)}
onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)}
>
{badge}
<div className="min-w-0 text-text-secondary system-sm-medium">
{item.title}
</div>
{hoveredSnippetId === item.id && item.author && (
<div className="ml-auto text-text-tertiary system-xs-regular">
{item.author}
</div>
)}
</div>
)
if (!item.description)
return <div key={item.id}>{row}</div>
return (
<Tooltip key={item.id}>
<TooltipTrigger
delay={0}
render={row}
/>
<TooltipContent
placement="left-start"
variant="plain"
popupClassName="!bg-transparent !p-0"
>
<SnippetDetailCard
author={item.author}
description={item.description}
relatedBlocks={item.relatedBlocks}
title={item.title}
triggerBadge={badge}
/>
</TooltipContent>
</Tooltip>
)
})}
</div>
)
}
export default memo(Snippets)

View File

@@ -40,6 +40,7 @@ export type TabsProps = {
noTools?: boolean
forceShowStartContent?: boolean // Force show Start content even when noBlocks=true
allowStartNodeSelection?: boolean // Allow user input option even when trigger node already exists (e.g. change-node flow or when no Start node yet).
snippetsElem?: React.ReactNode
}
const Tabs: FC<TabsProps> = ({
activeTab,
@@ -57,6 +58,7 @@ const Tabs: FC<TabsProps> = ({
noTools,
forceShowStartContent = false,
allowStartNodeSelection = false,
snippetsElem,
}) => {
const { t } = useTranslation()
const { data: buildInTools } = useAllBuiltInTools()
@@ -234,6 +236,13 @@ const Tabs: FC<TabsProps> = ({
/>
)
}
{
activeTab === TabsEnum.Snippets && snippetsElem && (
<div className="border-t border-divider-subtle">
{snippetsElem}
</div>
)
}
</div>
)
}

View File

@@ -169,7 +169,7 @@ const ToolPicker: FC<Props> = ({
{trigger}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1000]">
<PortalToFollowElemContent className="z-[1002]">
<div className={cn('relative min-h-20 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm', panelClassName)}>
<div className="p-2 pb-1">
<SearchBox

View File

@@ -7,6 +7,7 @@ export enum TabsEnum {
Blocks = 'blocks',
Tools = 'tools',
Sources = 'sources',
Snippets = 'snippets',
}
export enum ToolTypeEnum {

View File

@@ -0,0 +1,178 @@
'use client'
import type { FC } from 'react'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import { useKeyPress } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import { Dialog, DialogCloseButton, DialogContent, DialogPortal, DialogTitle } from '@/app/components/base/ui/dialog'
import ShortcutsName from './shortcuts-name'
export type CreateSnippetDialogPayload = {
name: string
description: string
icon: AppIconSelection
selectedNodeIds: string[]
}
type CreateSnippetDialogProps = {
isOpen: boolean
selectedNodeIds: string[]
onClose: () => void
onConfirm: (payload: CreateSnippetDialogPayload) => void
}
const defaultIcon: AppIconSelection = {
type: 'emoji',
icon: '🤖',
background: '#FFEAD5',
}
const CreateSnippetDialog: FC<CreateSnippetDialogProps> = ({
isOpen,
selectedNodeIds,
onClose,
onConfirm,
}) => {
const { t } = useTranslation()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [icon, setIcon] = useState<AppIconSelection>(defaultIcon)
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const resetForm = useCallback(() => {
setName('')
setDescription('')
setIcon(defaultIcon)
setShowAppIconPicker(false)
}, [])
const handleClose = useCallback(() => {
resetForm()
onClose()
}, [onClose, resetForm])
const handleConfirm = useCallback(() => {
const trimmedName = name.trim()
const trimmedDescription = description.trim()
if (!trimmedName)
return
const payload = {
name: trimmedName,
description: trimmedDescription,
icon,
selectedNodeIds,
}
onConfirm(payload)
Toast.notify({
type: 'success',
message: t('snippet.createSuccess', { ns: 'workflow' }),
})
handleClose()
}, [description, handleClose, icon, name, onConfirm, selectedNodeIds, t])
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
if (!isOpen)
return
handleConfirm()
})
return (
<>
<Dialog open={isOpen} onOpenChange={open => !open && handleClose()}>
<DialogContent className="w-[520px] max-w-[520px] p-0">
<DialogCloseButton />
<div className="px-6 pb-3 pt-6">
<DialogTitle className="text-text-primary title-2xl-semi-bold">
{t('snippet.createDialogTitle', { ns: 'workflow' })}
</DialogTitle>
</div>
<div className="space-y-4 px-6 py-2">
<div className="flex items-end gap-3">
<div className="flex-1 pb-0.5">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">
{t('snippet.nameLabel', { ns: 'workflow' })}
</div>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('snippet.namePlaceholder', { ns: 'workflow' }) || ''}
autoFocus
/>
</div>
<AppIcon
size="xxl"
className="shrink-0 cursor-pointer"
iconType={icon.type}
icon={icon.type === 'emoji' ? icon.icon : icon.fileId}
background={icon.type === 'emoji' ? icon.background : undefined}
imageUrl={icon.type === 'image' ? icon.url : undefined}
onClick={() => setShowAppIconPicker(true)}
/>
</div>
<div>
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">
{t('snippet.descriptionLabel', { ns: 'workflow' })}
</div>
<Textarea
className="resize-none"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder={t('snippet.descriptionPlaceholder', { ns: 'workflow' }) || ''}
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 pb-6 pt-5">
<Button onClick={handleClose}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
disabled={!name.trim()}
onClick={handleConfirm}
>
{t('snippet.confirm', { ns: 'workflow' })}
<ShortcutsName className="ml-1" keys={['ctrl', 'enter']} bgColor="white" />
</Button>
</div>
</DialogContent>
<DialogPortal>
<div className="pointer-events-none fixed left-1/2 top-1/2 z-[1002] flex -translate-x-1/2 translate-y-[170px] items-center gap-1 text-text-quaternary body-xs-regular">
<span>{t('snippet.shortcuts.press', { ns: 'workflow' })}</span>
<ShortcutsName keys={['ctrl', 'enter']} textColor="secondary" />
<span>{t('snippet.shortcuts.toConfirm', { ns: 'workflow' })}</span>
</div>
</DialogPortal>
</Dialog>
{showAppIconPicker && (
<AppIconPicker
className="z-[1100]"
onSelect={(selection) => {
setIcon(selection)
setShowAppIconPicker(false)
}}
onClose={() => setShowAppIconPicker(false)}
/>
)}
</>
)
}
export default CreateSnippetDialog

View File

@@ -6,7 +6,7 @@ import {
RiAlignRight,
RiAlignTop,
} from '@remixicon/react'
import { useClickAway } from 'ahooks'
import { useClickAway, useKeyPress } from 'ahooks'
import { produce } from 'immer'
import {
memo,
@@ -14,13 +14,17 @@ import {
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
import CreateSnippetDialog from './create-snippet-dialog'
import { useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { useSelectionInteractions } from './hooks/use-selection-interactions'
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
import ShortcutsName from './shortcuts-name'
import { useStore, useWorkflowStore } from './store'
import { BlockEnum, TRIGGER_NODE_TYPES } from './types'
enum AlignType {
Left = 'left',
@@ -39,6 +43,8 @@ const SelectionContextmenu = () => {
const { getNodesReadOnly } = useNodesReadOnly()
const { handleSelectionContextmenuCancel } = useSelectionInteractions()
const selectionMenu = useStore(s => s.selectionMenu)
const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false)
const [selectedNodeIdsSnapshot, setSelectedNodeIdsSnapshot] = useState<string[]>([])
// Access React Flow methods
const store = useStoreApi()
@@ -67,7 +73,7 @@ const SelectionContextmenu = () => {
const menuWidth = 240
const estimatedMenuHeight = 380
const estimatedMenuHeight = 420
if (left + menuWidth > containerWidth)
left = left - menuWidth
@@ -91,6 +97,32 @@ const SelectionContextmenu = () => {
handleSelectionContextmenuCancel()
}, [selectionMenu, selectedNodes.length, handleSelectionContextmenuCancel])
const isAddToSnippetDisabled = useMemo(() => {
return selectedNodes.some(node =>
node.data.type === BlockEnum.Start || TRIGGER_NODE_TYPES.includes(node.data.type as typeof TRIGGER_NODE_TYPES[number]))
}, [selectedNodes])
const handleOpenCreateSnippetDialog = useCallback(() => {
if (isAddToSnippetDisabled)
return
setSelectedNodeIdsSnapshot(selectedNodes.map(node => node.id))
setIsCreateSnippetDialogOpen(true)
handleSelectionContextmenuCancel()
}, [handleSelectionContextmenuCancel, isAddToSnippetDisabled, selectedNodes])
const handleCloseCreateSnippetDialog = useCallback(() => {
setIsCreateSnippetDialogOpen(false)
setSelectedNodeIdsSnapshot([])
}, [])
useKeyPress(['meta.g', 'ctrl.g'], () => {
if (!selectionMenu || isAddToSnippetDisabled)
return
handleOpenCreateSnippetDialog()
})
// Handle align nodes logic
const handleAlignNode = useCallback((currentNode: any, nodeToAlign: any, alignType: AlignType, minX: number, maxX: number, minY: number, maxY: number) => {
const width = nodeToAlign.width
@@ -369,88 +401,114 @@ const SelectionContextmenu = () => {
}
}, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel, handleAlignNode, handleDistributeNodes])
if (!selectionMenu)
if (!selectionMenu && !isCreateSnippetDialogOpen)
return null
return (
<div
className="absolute z-[9]"
style={{
left: menuPosition.left,
top: menuPosition.top,
}}
ref={ref}
>
<div ref={menuRef} className="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
<div className="p-1">
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
{t('operator.vertical', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Top)}
>
<RiAlignTop className="h-4 w-4" />
{t('operator.alignTop', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Middle)}
>
<RiAlignCenter className="h-4 w-4 rotate-90" />
{t('operator.alignMiddle', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Bottom)}
>
<RiAlignBottom className="h-4 w-4" />
{t('operator.alignBottom', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.DistributeVertical)}
>
<RiAlignJustify className="h-4 w-4 rotate-90" />
{t('operator.distributeVertical', { ns: 'workflow' })}
<>
{selectionMenu && (
<div
className="absolute z-[9]"
style={{
left: menuPosition.left,
top: menuPosition.top,
}}
ref={ref}
>
<div ref={menuRef} className="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
<div className="p-1">
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-30 data-[disabled=true]:hover:bg-transparent"
data-disabled={isAddToSnippetDisabled}
aria-disabled={isAddToSnippetDisabled}
onClick={handleOpenCreateSnippetDialog}
>
<span>{t('snippet.addToSnippet', { ns: 'workflow' })}</span>
{!isAddToSnippetDisabled && (
<ShortcutsName className="ml-auto" keys={['ctrl', 'g']} textColor="secondary" />
)}
</div>
</div>
<div className="h-px bg-divider-regular"></div>
<div className="p-1">
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
{t('operator.vertical', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Top)}
>
<RiAlignTop className="h-4 w-4" />
{t('operator.alignTop', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Middle)}
>
<RiAlignCenter className="h-4 w-4 rotate-90" />
{t('operator.alignMiddle', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Bottom)}
>
<RiAlignBottom className="h-4 w-4" />
{t('operator.alignBottom', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.DistributeVertical)}
>
<RiAlignJustify className="h-4 w-4 rotate-90" />
{t('operator.distributeVertical', { ns: 'workflow' })}
</div>
</div>
<div className="h-px bg-divider-regular"></div>
<div className="p-1">
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
{t('operator.horizontal', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Left)}
>
<RiAlignLeft className="h-4 w-4" />
{t('operator.alignLeft', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Center)}
>
<RiAlignCenter className="h-4 w-4" />
{t('operator.alignCenter', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Right)}
>
<RiAlignRight className="h-4 w-4" />
{t('operator.alignRight', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.DistributeHorizontal)}
>
<RiAlignJustify className="h-4 w-4" />
{t('operator.distributeHorizontal', { ns: 'workflow' })}
</div>
</div>
</div>
</div>
<div className="h-px bg-divider-regular"></div>
<div className="p-1">
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
{t('operator.horizontal', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Left)}
>
<RiAlignLeft className="h-4 w-4" />
{t('operator.alignLeft', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Center)}
>
<RiAlignCenter className="h-4 w-4" />
{t('operator.alignCenter', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.Right)}
>
<RiAlignRight className="h-4 w-4" />
{t('operator.alignRight', { ns: 'workflow' })}
</div>
<div
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => handleAlignNodes(AlignType.DistributeHorizontal)}
>
<RiAlignJustify className="h-4 w-4" />
{t('operator.distributeHorizontal', { ns: 'workflow' })}
</div>
</div>
</div>
</div>
)}
<CreateSnippetDialog
isOpen={isCreateSnippetDialogOpen}
selectedNodeIds={selectedNodeIdsSnapshot}
onClose={handleCloseCreateSnippetDialog}
onConfirm={(payload) => {
void payload
}}
/>
</>
)
}

View File

@@ -928,12 +928,6 @@
"app/components/app/configuration/dataset-config/select-dataset/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
"tailwindcss/no-unnecessary-whitespace": {
"count": 2
}
},
"app/components/app/configuration/dataset-config/settings-modal/index.tsx": {

View File

@@ -14,6 +14,7 @@ import type datasetPipeline from '../i18n/en-US/dataset-pipeline.json'
import type datasetSettings from '../i18n/en-US/dataset-settings.json'
import type dataset from '../i18n/en-US/dataset.json'
import type education from '../i18n/en-US/education.json'
import type evaluation from '../i18n/en-US/evaluation.json'
import type explore from '../i18n/en-US/explore.json'
import type layout from '../i18n/en-US/layout.json'
import type login from '../i18n/en-US/login.json'
@@ -25,6 +26,7 @@ import type plugin from '../i18n/en-US/plugin.json'
import type register from '../i18n/en-US/register.json'
import type runLog from '../i18n/en-US/run-log.json'
import type share from '../i18n/en-US/share.json'
import type snippet from '../i18n/en-US/snippet.json'
import type time from '../i18n/en-US/time.json'
import type tools from '../i18n/en-US/tools.json'
import type workflow from '../i18n/en-US/workflow.json'
@@ -47,6 +49,7 @@ export type Resources = {
datasetPipeline: typeof datasetPipeline
datasetSettings: typeof datasetSettings
education: typeof education
evaluation: typeof evaluation
explore: typeof explore
layout: typeof layout
login: typeof login
@@ -58,6 +61,7 @@ export type Resources = {
register: typeof register
runLog: typeof runLog
share: typeof share
snippet: typeof snippet
time: typeof time
tools: typeof tools
workflow: typeof workflow
@@ -80,6 +84,7 @@ export const namespaces = [
'datasetPipeline',
'datasetSettings',
'education',
'evaluation',
'explore',
'layout',
'login',
@@ -91,6 +96,7 @@ export const namespaces = [
'register',
'runLog',
'share',
'snippet',
'time',
'tools',
'workflow',

View File

@@ -208,6 +208,13 @@
"structOutput.required": "Required",
"structOutput.structured": "Structured",
"structOutput.structuredTip": "Structured Outputs is a feature that ensures the model will always generate responses that adhere to your supplied JSON Schema",
"studio.apps": "Apps",
"studio.filters.allCreators": "All creators",
"studio.filters.creators": "Creators",
"studio.filters.reset": "Reset",
"studio.filters.searchCreators": "Search creator...",
"studio.filters.types": "Types",
"studio.filters.you": "You",
"switch": "Switch to Workflow Orchestrate",
"switchLabel": "The app copy to be created",
"switchStart": "Start switch",

View File

@@ -93,6 +93,7 @@
"apiBasedExtension.type": "Type",
"appMenus.apiAccess": "API Access",
"appMenus.apiAccessTip": "This knowledge base is accessible via the Service API",
"appMenus.evaluation": "Evaluation",
"appMenus.logAndAnn": "Logs & Annotations",
"appMenus.logs": "Logs",
"appMenus.overview": "Monitoring",
@@ -149,6 +150,7 @@
"dataSource.website.with": "With",
"datasetMenus.documents": "Documents",
"datasetMenus.emptyTip": "This Knowledge has not been integrated within any application. Please refer to the document for guidance.",
"datasetMenus.evaluation": "Evaluation",
"datasetMenus.hitTesting": "Retrieval Testing",
"datasetMenus.noRelatedApp": "No linked apps",
"datasetMenus.pipeline": "Pipeline",

View File

@@ -0,0 +1,73 @@
{
"batch.downloadTemplate": "Download Excel Template",
"batch.emptyHistory": "No test history yet.",
"batch.noticeDescription": "Download the template, upload your cases, then run a local mock batch test.",
"batch.noticeTitle": "Quick start",
"batch.requirementsTitle": "Data requirements",
"batch.run": "Run Test",
"batch.status.failed": "Failed",
"batch.status.running": "Running",
"batch.status.success": "Success",
"batch.tabs.history": "Test History",
"batch.tabs.input-fields": "Input Fields",
"batch.title": "Batch Test",
"batch.uploadHint": "Select a .csv or .xlsx file",
"batch.uploadTitle": "Upload test file",
"batch.validation": "Complete the judge model, metrics, and custom mappings before running a batch test.",
"conditions.addCondition": "Add Condition",
"conditions.addGroup": "Add Condition Group",
"conditions.boolean.false": "False",
"conditions.boolean.true": "True",
"conditions.description": "Define additional rules for when results should pass or fail.",
"conditions.emptyDescription": "Start with a condition group to define evaluation gates.",
"conditions.emptyTitle": "No conditions yet",
"conditions.fieldPlaceholder": "Select field",
"conditions.groupLabel": "Group {{index}}",
"conditions.logical.and": "AND",
"conditions.logical.or": "OR",
"conditions.operators.after": "After",
"conditions.operators.before": "Before",
"conditions.operators.contains": "Contains",
"conditions.operators.greater_or_equal": "Greater than or equal",
"conditions.operators.greater_than": "Greater than",
"conditions.operators.is": "Is",
"conditions.operators.is_empty": "Is empty",
"conditions.operators.is_not": "Is not",
"conditions.operators.is_not_empty": "Is not empty",
"conditions.operators.less_or_equal": "Less than or equal",
"conditions.operators.less_than": "Less than",
"conditions.operators.not_contains": "Does not contain",
"conditions.removeCondition": "Remove condition",
"conditions.removeGroup": "Remove condition group",
"conditions.selectFieldFirst": "Select a field first",
"conditions.selectTime": "Choose a time...",
"conditions.selectValue": "Choose a value",
"conditions.title": "Judgment Conditions",
"conditions.valuePlaceholder": "Enter a value",
"description": "Configure judge models, metrics, and batch tests for this resource.",
"judgeModel.description": "Choose the model used to score your evaluation results.",
"judgeModel.title": "Judge Model",
"metrics.add": "Add Metric",
"metrics.addCustom": "Add Custom Metrics",
"metrics.added": "Added",
"metrics.custom.addMapping": "Add Mapping",
"metrics.custom.description": "Select an evaluation workflow and map your variables before running tests.",
"metrics.custom.mappingTitle": "Variable Mapping",
"metrics.custom.mappingWarning": "Complete the workflow selection and each variable mapping to enable batch tests.",
"metrics.custom.sourcePlaceholder": "Source variable",
"metrics.custom.targetPlaceholder": "Target variable",
"metrics.custom.title": "Custom Evaluator",
"metrics.custom.warningBadge": "Needs setup",
"metrics.custom.workflowLabel": "Evaluation Workflow",
"metrics.custom.workflowPlaceholder": "Select a workflow",
"metrics.description": "Combine built-in metrics with custom evaluator workflows.",
"metrics.groups.operations": "Operations",
"metrics.groups.quality": "Quality",
"metrics.noResults": "No metrics match your search.",
"metrics.remove": "Remove metric",
"metrics.searchPlaceholder": "Search metrics",
"metrics.showLess": "Show less",
"metrics.showMore": "Show more",
"metrics.title": "Metrics",
"title": "Evaluation"
}

View File

@@ -0,0 +1,16 @@
{
"create": "CREATE SNIPPET",
"inputFieldButton": "Input Field",
"notFoundDescription": "The requested snippet mock was not found.",
"notFoundTitle": "Snippet not found",
"panelDescription": "Defines the input fields that allow the snippet to receive data from other nodes.",
"panelPrimaryGroup": "Core inputs",
"panelSecondaryGroup": "Optional inputs",
"panelTitle": "Input Field",
"publishButton": "Publish",
"publishMenuCurrentDraft": "Current draft unpublished",
"sectionEvaluation": "Evaluation",
"sectionOrchestrate": "Orchestrate",
"testRunButton": "Test run",
"variableInspect": "Variable Inspect"
}

View File

@@ -1083,6 +1083,16 @@
"singleRun.testRun": "Test Run",
"singleRun.testRunIteration": "Test Run Iteration",
"singleRun.testRunLoop": "Test Run Loop",
"snippet.addToSnippet": "Add to snippet",
"snippet.confirm": "Confirm",
"snippet.createDialogTitle": "Create Snippet",
"snippet.createSuccess": "Snippet created",
"snippet.descriptionLabel": "Description (Optional)",
"snippet.descriptionPlaceholder": "Briefly describe your snippet",
"snippet.nameLabel": "Snippet Name & Icon",
"snippet.namePlaceholder": "Snippet name",
"snippet.shortcuts.press": "Press",
"snippet.shortcuts.toConfirm": "to confirm",
"tabs.-": "Default",
"tabs.addAll": "Add all",
"tabs.agent": "Agent Strategy",
@@ -1090,6 +1100,7 @@
"tabs.allTool": "All",
"tabs.allTriggers": "All triggers",
"tabs.blocks": "Nodes",
"tabs.createSnippet": "Create a snippet",
"tabs.customTool": "Custom",
"tabs.featuredTools": "Featured",
"tabs.hideActions": "Hide tools",
@@ -1099,16 +1110,19 @@
"tabs.noFeaturedTriggers": "Discover more triggers in Marketplace",
"tabs.noPluginsFound": "No plugins were found",
"tabs.noResult": "No match found",
"tabs.noSnippetsFound": "No snippets were found",
"tabs.plugin": "Plugin",
"tabs.pluginByAuthor": "By {{author}}",
"tabs.question-understand": "Question Understand",
"tabs.requestToCommunity": "Requests to the community",
"tabs.searchBlock": "Search node",
"tabs.searchDataSource": "Search Data Source",
"tabs.searchSnippets": "Search snippets...",
"tabs.searchTool": "Search tool",
"tabs.searchTrigger": "Search triggers...",
"tabs.showLessFeatured": "Show less",
"tabs.showMoreFeatured": "Show more",
"tabs.snippets": "Snippets",
"tabs.sources": "Sources",
"tabs.start": "Start",
"tabs.startDisabledTip": "Trigger node and user input node are mutually exclusive.",

View File

@@ -93,6 +93,7 @@
"apiBasedExtension.type": "类型",
"appMenus.apiAccess": "访问 API",
"appMenus.apiAccessTip": "此知识库可通过服务 API 访问",
"appMenus.evaluation": "评测",
"appMenus.logAndAnn": "日志与标注",
"appMenus.logs": "日志",
"appMenus.overview": "监测",
@@ -149,6 +150,7 @@
"dataSource.website.with": "使用",
"datasetMenus.documents": "文档",
"datasetMenus.emptyTip": "此知识尚未集成到任何应用程序中。请参阅文档以获取指导。",
"datasetMenus.evaluation": "评测",
"datasetMenus.hitTesting": "召回测试",
"datasetMenus.noRelatedApp": "无关联应用",
"datasetMenus.pipeline": "流水线",

View File

@@ -0,0 +1,73 @@
{
"batch.downloadTemplate": "下载 Excel 模板",
"batch.emptyHistory": "还没有测试历史。",
"batch.noticeDescription": "先下载模板,再上传测试集,然后运行本地模拟批量测试。",
"batch.noticeTitle": "快速开始",
"batch.requirementsTitle": "数据要求",
"batch.run": "运行测试",
"batch.status.failed": "失败",
"batch.status.running": "运行中",
"batch.status.success": "成功",
"batch.tabs.history": "测试历史",
"batch.tabs.input-fields": "输入字段",
"batch.title": "批量测试",
"batch.uploadHint": "选择 .csv 或 .xlsx 文件",
"batch.uploadTitle": "上传测试文件",
"batch.validation": "运行批量测试前,请先完成判定模型、指标和自定义映射配置。",
"conditions.addCondition": "添加条件",
"conditions.addGroup": "添加条件组",
"conditions.boolean.false": "否",
"conditions.boolean.true": "是",
"conditions.description": "定义额外规则,决定结果何时通过或失败。",
"conditions.emptyDescription": "从一个条件组开始,定义评测门槛。",
"conditions.emptyTitle": "还没有条件",
"conditions.fieldPlaceholder": "选择字段",
"conditions.groupLabel": "条件组 {{index}}",
"conditions.logical.and": "且",
"conditions.logical.or": "或",
"conditions.operators.after": "晚于",
"conditions.operators.before": "早于",
"conditions.operators.contains": "包含",
"conditions.operators.greater_or_equal": "大于等于",
"conditions.operators.greater_than": "大于",
"conditions.operators.is": "等于",
"conditions.operators.is_empty": "为空",
"conditions.operators.is_not": "不等于",
"conditions.operators.is_not_empty": "不为空",
"conditions.operators.less_or_equal": "小于等于",
"conditions.operators.less_than": "小于",
"conditions.operators.not_contains": "不包含",
"conditions.removeCondition": "删除条件",
"conditions.removeGroup": "删除条件组",
"conditions.selectFieldFirst": "请先选择字段",
"conditions.selectTime": "选择时间...",
"conditions.selectValue": "选择值",
"conditions.title": "判定条件",
"conditions.valuePlaceholder": "输入值",
"description": "为当前资源配置判定模型、评测指标和批量测试。",
"judgeModel.description": "选择用于打分和判定评测结果的模型。",
"judgeModel.title": "判定模型",
"metrics.add": "添加指标",
"metrics.addCustom": "添加自定义指标",
"metrics.added": "已添加",
"metrics.custom.addMapping": "添加映射",
"metrics.custom.description": "选择评测工作流并完成变量映射后即可运行测试。",
"metrics.custom.mappingTitle": "变量映射",
"metrics.custom.mappingWarning": "请先完成工作流选择和所有变量映射,再运行批量测试。",
"metrics.custom.sourcePlaceholder": "源变量",
"metrics.custom.targetPlaceholder": "目标变量",
"metrics.custom.title": "自定义评测器",
"metrics.custom.warningBadge": "待配置",
"metrics.custom.workflowLabel": "评测工作流",
"metrics.custom.workflowPlaceholder": "选择工作流",
"metrics.description": "组合内置指标和自定义评测工作流。",
"metrics.groups.operations": "运行",
"metrics.groups.quality": "质量",
"metrics.noResults": "没有匹配的指标。",
"metrics.remove": "删除指标",
"metrics.searchPlaceholder": "搜索指标",
"metrics.showLess": "收起",
"metrics.showMore": "展开更多",
"metrics.title": "指标",
"title": "评测"
}

View File

@@ -0,0 +1,16 @@
{
"create": "创建 Snippet",
"inputFieldButton": "输入字段",
"notFoundDescription": "未找到对应的 snippet 静态数据。",
"notFoundTitle": "未找到 Snippet",
"panelDescription": "定义允许 snippet 从其他节点接收数据的输入字段。",
"panelPrimaryGroup": "核心输入",
"panelSecondaryGroup": "可选输入",
"panelTitle": "输入字段",
"publishButton": "发布",
"publishMenuCurrentDraft": "当前草稿未发布",
"sectionEvaluation": "评测",
"sectionOrchestrate": "编排",
"testRunButton": "测试运行",
"variableInspect": "变量查看"
}

View File

@@ -1083,6 +1083,16 @@
"singleRun.testRun": "测试运行",
"singleRun.testRunIteration": "测试运行迭代",
"singleRun.testRunLoop": "测试运行循环",
"snippet.addToSnippet": "添加到 snippet",
"snippet.confirm": "确认",
"snippet.createDialogTitle": "创建 Snippet",
"snippet.createSuccess": "Snippet 已创建",
"snippet.descriptionLabel": "描述(可选)",
"snippet.descriptionPlaceholder": "简要描述你的 snippet",
"snippet.nameLabel": "Snippet 名称和图标",
"snippet.namePlaceholder": "Snippet 名称",
"snippet.shortcuts.press": "按下",
"snippet.shortcuts.toConfirm": "确认",
"tabs.-": "默认",
"tabs.addAll": "添加全部",
"tabs.agent": "Agent 策略",
@@ -1090,6 +1100,7 @@
"tabs.allTool": "全部",
"tabs.allTriggers": "全部触发器",
"tabs.blocks": "节点",
"tabs.createSnippet": "创建 snippet",
"tabs.customTool": "自定义",
"tabs.featuredTools": "精选推荐",
"tabs.hideActions": "收起工具",
@@ -1099,16 +1110,19 @@
"tabs.noFeaturedTriggers": "前往插件市场查看更多触发器",
"tabs.noPluginsFound": "未找到插件",
"tabs.noResult": "未找到匹配项",
"tabs.noSnippetsFound": "未找到 snippets",
"tabs.plugin": "插件",
"tabs.pluginByAuthor": "来自 {{author}}",
"tabs.question-understand": "问题理解",
"tabs.requestToCommunity": "向社区反馈",
"tabs.searchBlock": "搜索节点",
"tabs.searchDataSource": "搜索数据源",
"tabs.searchSnippets": "搜索 snippets...",
"tabs.searchTool": "搜索工具",
"tabs.searchTrigger": "搜索触发器...",
"tabs.showLessFeatured": "收起",
"tabs.showMoreFeatured": "查看更多",
"tabs.snippets": "Snippets",
"tabs.sources": "数据源",
"tabs.start": "开始",
"tabs.startDisabledTip": "触发节点与用户输入节点互斥。",

50
web/models/snippet.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { Viewport } from 'reactflow'
import type { Edge, Node } from '@/app/components/workflow/types'
import type { InputVar } from '@/models/pipeline'
export type SnippetSection = 'orchestrate' | 'evaluation'
export type SnippetListItem = {
id: string
name: string
description: string
author: string
updatedAt: string
usage: string
icon: string
iconBackground: string
status?: string
}
export type SnippetDetail = {
id: string
name: string
description: string
author: string
updatedAt: string
usage: string
icon: string
iconBackground: string
status?: string
}
export type SnippetCanvasData = {
nodes: Node[]
edges: Edge[]
viewport: Viewport
}
export type SnippetInputField = InputVar
export type SnippetDetailUIModel = {
inputFieldCount: number
checklistCount: number
autoSavedAt: string
}
export type SnippetDetailPayload = {
snippet: SnippetDetail
graph: SnippetCanvasData
inputFields: SnippetInputField[]
uiMeta: SnippetDetailUIModel
}

View File

@@ -131,14 +131,15 @@ export function getChangedBranchCoverage(entry, changedLines) {
let total = 0
for (const [branchId, branch] of Object.entries(entry.branchMap ?? {})) {
if (!branchIntersectsChangedLines(branch, changedLines))
continue
const hits = Array.isArray(entry.b?.[branchId]) ? entry.b[branchId] : []
const locations = getBranchLocations(branch)
const armCount = Math.max(locations.length, hits.length)
const impactedArmIndexes = getImpactedBranchArmIndexes(branch, changedLines, armCount)
for (let armIndex = 0; armIndex < armCount; armIndex += 1) {
if (impactedArmIndexes.length === 0)
continue
for (const armIndex of impactedArmIndexes) {
total += 1
if ((hits[armIndex] ?? 0) > 0) {
covered += 1
@@ -219,22 +220,50 @@ function emptyIgnoreResult(changedLines = []) {
}
}
function branchIntersectsChangedLines(branch, changedLines) {
function getBranchLocations(branch) {
return Array.isArray(branch?.locations) ? branch.locations.filter(Boolean) : []
}
function getImpactedBranchArmIndexes(branch, changedLines, armCount) {
if (!changedLines || changedLines.size === 0 || armCount === 0)
return []
const locations = getBranchLocations(branch)
if (isWholeBranchTouched(branch, changedLines, locations, armCount))
return Array.from({ length: armCount }, (_, armIndex) => armIndex)
const impactedArmIndexes = []
for (let armIndex = 0; armIndex < armCount; armIndex += 1) {
const location = locations[armIndex]
if (rangeIntersectsChangedLines(location, changedLines))
impactedArmIndexes.push(armIndex)
}
return impactedArmIndexes
}
function isWholeBranchTouched(branch, changedLines, locations, armCount) {
if (!changedLines || changedLines.size === 0)
return false
if (rangeIntersectsChangedLines(branch.loc, changedLines))
if (branch.line && changedLines.has(branch.line))
return true
const locations = getBranchLocations(branch)
if (locations.some(location => rangeIntersectsChangedLines(location, changedLines)))
const branchRange = branch.loc ?? branch
if (!rangeIntersectsChangedLines(branchRange, changedLines))
return false
if (locations.length === 0 || locations.length < armCount)
return true
return branch.line ? changedLines.has(branch.line) : false
}
for (const lineNumber of changedLines) {
if (!lineTouchesLocation(lineNumber, branchRange))
continue
if (!locations.some(location => lineTouchesLocation(lineNumber, location)))
return true
}
function getBranchLocations(branch) {
return Array.isArray(branch?.locations) ? branch.locations.filter(Boolean) : []
return false
}
function rangeIntersectsChangedLines(location, changedLines) {
@@ -268,6 +297,15 @@ function getFirstChangedLineInRange(location, changedLines, fallbackLine = 1) {
return startLine ?? fallbackLine
}
function lineTouchesLocation(lineNumber, location) {
const startLine = getLocationStartLine(location)
const endLine = getLocationEndLine(location) ?? startLine
if (!startLine || !endLine)
return false
return lineNumber >= startLine && lineNumber <= endLine
}
function getLocationStartLine(location) {
return location?.start?.line ?? location?.line ?? null
}

View File

@@ -6,41 +6,34 @@ import {
getChangedBranchCoverage,
getChangedStatementCoverage,
getIgnoredChangedLinesFromFile,
getLineHits,
normalizeToRepoRelative,
parseChangedLineMap,
} from './check-components-diff-coverage-lib.mjs'
import { COMPONENT_COVERAGE_EXCLUDE_LABEL } from './component-coverage-filters.mjs'
import {
collectComponentCoverageExcludedFiles,
COMPONENT_COVERAGE_EXCLUDE_LABEL,
} from './component-coverage-filters.mjs'
import {
COMPONENTS_GLOBAL_THRESHOLDS,
EXCLUDED_COMPONENT_MODULES,
getComponentModuleThreshold,
} from './components-coverage-thresholds.mjs'
APP_COMPONENTS_PREFIX,
createComponentCoverageContext,
getModuleName,
isAnyComponentSourceFile,
isExcludedComponentSourceFile,
isTrackedComponentSourceFile,
loadTrackedCoverageEntries,
} from './components-coverage-common.mjs'
import { EXCLUDED_COMPONENT_MODULES } from './components-coverage-thresholds.mjs'
const APP_COMPONENTS_PREFIX = 'web/app/components/'
const APP_COMPONENTS_COVERAGE_PREFIX = 'app/components/'
const SHARED_TEST_PREFIX = 'web/__tests__/'
const STRICT_TEST_FILE_TOUCH = process.env.STRICT_COMPONENT_TEST_TOUCH === 'true'
const EXCLUDED_MODULES_LABEL = [...EXCLUDED_COMPONENT_MODULES].sort().join(', ')
const DIFF_RANGE_MODE = process.env.DIFF_RANGE_MODE === 'exact' ? 'exact' : 'merge-base'
const EXCLUDED_MODULES_LABEL = [...EXCLUDED_COMPONENT_MODULES].sort().join(', ')
const repoRoot = repoRootFromCwd()
const webRoot = path.join(repoRoot, 'web')
const excludedComponentCoverageFiles = new Set(
collectComponentCoverageExcludedFiles(path.join(webRoot, 'app/components'), { pathPrefix: 'web/app/components' }),
)
const context = createComponentCoverageContext(repoRoot)
const baseSha = process.env.BASE_SHA?.trim()
const headSha = process.env.HEAD_SHA?.trim() || 'HEAD'
const coverageFinalPath = path.join(webRoot, 'coverage', 'coverage-final.json')
const coverageFinalPath = path.join(context.webRoot, 'coverage', 'coverage-final.json')
if (!baseSha || /^0+$/.test(baseSha)) {
appendSummary([
'### app/components Diff Coverage',
'### app/components Pure Diff Coverage',
'',
'Skipped diff coverage check because `BASE_SHA` was not available.',
'Skipped pure diff coverage check because `BASE_SHA` was not available.',
])
process.exit(0)
}
@@ -53,52 +46,27 @@ if (!fs.existsSync(coverageFinalPath)) {
const coverage = JSON.parse(fs.readFileSync(coverageFinalPath, 'utf8'))
const changedFiles = getChangedFiles(baseSha, headSha)
const changedComponentSourceFiles = changedFiles.filter(isAnyComponentSourceFile)
const changedSourceFiles = changedComponentSourceFiles.filter(isTrackedComponentSourceFile)
const changedExcludedSourceFiles = changedComponentSourceFiles.filter(isExcludedComponentSourceFile)
const changedTestFiles = changedFiles.filter(isRelevantTestFile)
const changedSourceFiles = changedComponentSourceFiles.filter(filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles))
const changedExcludedSourceFiles = changedComponentSourceFiles.filter(filePath => isExcludedComponentSourceFile(filePath, context.excludedComponentCoverageFiles))
if (changedSourceFiles.length === 0) {
appendSummary(buildSkipSummary(changedExcludedSourceFiles))
process.exit(0)
}
const coverageEntries = new Map()
for (const [file, entry] of Object.entries(coverage)) {
const repoRelativePath = normalizeToRepoRelative(entry.path ?? file, {
appComponentsCoveragePrefix: APP_COMPONENTS_COVERAGE_PREFIX,
appComponentsPrefix: APP_COMPONENTS_PREFIX,
repoRoot,
sharedTestPrefix: SHARED_TEST_PREFIX,
webRoot,
})
if (!isTrackedComponentSourceFile(repoRelativePath))
continue
coverageEntries.set(repoRelativePath, entry)
}
const fileCoverageRows = []
const moduleCoverageMap = new Map()
for (const [file, entry] of coverageEntries.entries()) {
const stats = getCoverageStats(entry)
const moduleName = getModuleName(file)
fileCoverageRows.push({ file, moduleName, ...stats })
mergeCoverageStats(moduleCoverageMap, moduleName, stats)
}
const overallCoverage = sumCoverageStats(fileCoverageRows)
const coverageEntries = loadTrackedCoverageEntries(coverage, context)
const diffChanges = getChangedLineMap(baseSha, headSha)
const diffRows = []
const ignoredDiffLines = []
const invalidIgnorePragmas = []
for (const [file, changedLines] of diffChanges.entries()) {
if (!isTrackedComponentSourceFile(file))
if (!isTrackedComponentSourceFile(file, context.excludedComponentCoverageFiles))
continue
const entry = coverageEntries.get(file)
const ignoreInfo = getIgnoredChangedLinesFromFile(path.join(repoRoot, file), changedLines)
for (const [line, reason] of ignoreInfo.ignoredLines.entries()) {
ignoredDiffLines.push({
file,
@@ -106,6 +74,7 @@ for (const [file, changedLines] of diffChanges.entries()) {
reason,
})
}
for (const invalidPragma of ignoreInfo.invalidPragmas) {
invalidIgnorePragmas.push({
file,
@@ -137,40 +106,15 @@ const diffTotals = diffRows.reduce((acc, row) => {
const diffStatementFailures = diffRows.filter(row => row.statements.uncoveredLines.length > 0)
const diffBranchFailures = diffRows.filter(row => row.branches.uncoveredBranches.length > 0)
const overallThresholdFailures = getThresholdFailures(overallCoverage, COMPONENTS_GLOBAL_THRESHOLDS)
const moduleCoverageRows = [...moduleCoverageMap.entries()]
.map(([moduleName, stats]) => ({
moduleName,
stats,
thresholds: getComponentModuleThreshold(moduleName),
}))
.map(row => ({
...row,
failures: row.thresholds ? getThresholdFailures(row.stats, row.thresholds) : [],
}))
const moduleThresholdFailures = moduleCoverageRows
.filter(row => row.failures.length > 0)
.flatMap(row => row.failures.map(failure => ({
moduleName: row.moduleName,
...failure,
})))
const hasRelevantTestChanges = changedTestFiles.length > 0
const missingTestTouch = !hasRelevantTestChanges
appendSummary(buildSummary({
overallCoverage,
overallThresholdFailures,
moduleCoverageRows,
moduleThresholdFailures,
changedSourceFiles,
diffBranchFailures,
diffRows,
diffStatementFailures,
diffTotals,
changedSourceFiles,
changedTestFiles,
ignoredDiffLines,
invalidIgnorePragmas,
missingTestTouch,
}))
if (process.env.CI) {
@@ -178,44 +122,37 @@ if (process.env.CI) {
const firstLine = failure.statements.uncoveredLines[0] ?? 1
console.log(`::error file=${failure.file},line=${firstLine}::Uncovered changed statements: ${formatLineRanges(failure.statements.uncoveredLines)}`)
}
for (const failure of diffBranchFailures.slice(0, 20)) {
const firstBranch = failure.branches.uncoveredBranches[0]
const line = firstBranch?.line ?? 1
console.log(`::error file=${failure.file},line=${line}::Uncovered changed branches: ${formatBranchRefs(failure.branches.uncoveredBranches)}`)
}
for (const invalidPragma of invalidIgnorePragmas.slice(0, 20)) {
console.log(`::error file=${invalidPragma.file},line=${invalidPragma.line}::Invalid diff coverage ignore pragma: ${invalidPragma.reason}`)
}
}
if (
overallThresholdFailures.length > 0
|| moduleThresholdFailures.length > 0
|| diffStatementFailures.length > 0
diffStatementFailures.length > 0
|| diffBranchFailures.length > 0
|| invalidIgnorePragmas.length > 0
|| (STRICT_TEST_FILE_TOUCH && missingTestTouch)
) {
process.exit(1)
}
function buildSummary({
overallCoverage,
overallThresholdFailures,
moduleCoverageRows,
moduleThresholdFailures,
changedSourceFiles,
diffBranchFailures,
diffRows,
diffStatementFailures,
diffTotals,
changedSourceFiles,
changedTestFiles,
ignoredDiffLines,
invalidIgnorePragmas,
missingTestTouch,
}) {
const lines = [
'### app/components Diff Coverage',
'### app/components Pure Diff Coverage',
'',
`Compared \`${baseSha.slice(0, 12)}\` -> \`${headSha.slice(0, 12)}\``,
`Diff range mode: \`${DIFF_RANGE_MODE}\``,
@@ -225,60 +162,11 @@ function buildSummary({
'',
'| Check | Result | Details |',
'|---|---:|---|',
`| Overall tracked lines | ${formatPercent(overallCoverage.lines)} | ${overallCoverage.lines.covered}/${overallCoverage.lines.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.lines}% |`,
`| Overall tracked statements | ${formatPercent(overallCoverage.statements)} | ${overallCoverage.statements.covered}/${overallCoverage.statements.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.statements}% |`,
`| Overall tracked functions | ${formatPercent(overallCoverage.functions)} | ${overallCoverage.functions.covered}/${overallCoverage.functions.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.functions}% |`,
`| Overall tracked branches | ${formatPercent(overallCoverage.branches)} | ${overallCoverage.branches.covered}/${overallCoverage.branches.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.branches}% |`,
`| Changed statements | ${formatDiffPercent(diffTotals.statements)} | ${diffTotals.statements.covered}/${diffTotals.statements.total} |`,
`| Changed branches | ${formatDiffPercent(diffTotals.branches)} | ${diffTotals.branches.covered}/${diffTotals.branches.total} |`,
'',
]
if (overallThresholdFailures.length > 0) {
lines.push('Overall thresholds failed:')
for (const failure of overallThresholdFailures)
lines.push(`- ${failure.metric}: ${failure.actual.toFixed(2)}% < ${failure.expected}%`)
lines.push('')
}
if (moduleThresholdFailures.length > 0) {
lines.push('Module thresholds failed:')
for (const failure of moduleThresholdFailures)
lines.push(`- ${failure.moduleName} ${failure.metric}: ${failure.actual.toFixed(2)}% < ${failure.expected}%`)
lines.push('')
}
const moduleRows = moduleCoverageRows
.map(({ moduleName, stats, thresholds, failures }) => ({
moduleName,
lines: percentage(stats.lines.covered, stats.lines.total),
statements: percentage(stats.statements.covered, stats.statements.total),
functions: percentage(stats.functions.covered, stats.functions.total),
branches: percentage(stats.branches.covered, stats.branches.total),
thresholds,
failures,
}))
.sort((a, b) => {
if (a.failures.length !== b.failures.length)
return b.failures.length - a.failures.length
return a.lines - b.lines || a.moduleName.localeCompare(b.moduleName)
})
lines.push('<details><summary>Module coverage</summary>')
lines.push('')
lines.push('| Module | Lines | Statements | Functions | Branches | Thresholds | Status |')
lines.push('|---|---:|---:|---:|---:|---|---|')
for (const row of moduleRows) {
const thresholdLabel = row.thresholds
? `L${row.thresholds.lines}/S${row.thresholds.statements}/F${row.thresholds.functions}/B${row.thresholds.branches}`
: 'n/a'
const status = row.thresholds ? (row.failures.length > 0 ? 'fail' : 'pass') : 'info'
lines.push(`| ${row.moduleName} | ${row.lines.toFixed(2)}% | ${row.statements.toFixed(2)}% | ${row.functions.toFixed(2)}% | ${row.branches.toFixed(2)}% | ${thresholdLabel} | ${status} |`)
}
lines.push('</details>')
lines.push('')
const changedRows = diffRows
.filter(row => row.statements.total > 0 || row.branches.total > 0)
.sort((a, b) => {
@@ -297,59 +185,43 @@ function buildSummary({
lines.push('</details>')
lines.push('')
if (missingTestTouch) {
lines.push(`Warning: tracked source files changed under \`web/app/components/\`, but no test files changed under \`web/app/components/**\` or \`web/__tests__/\`.`)
if (STRICT_TEST_FILE_TOUCH)
lines.push('`STRICT_COMPONENT_TEST_TOUCH=true` is enabled, so this warning fails the check.')
lines.push('')
}
else {
lines.push(`Relevant test files changed: ${changedTestFiles.length}`)
lines.push('')
}
if (diffStatementFailures.length > 0) {
lines.push('Uncovered changed statements:')
for (const row of diffStatementFailures) {
for (const row of diffStatementFailures)
lines.push(`- ${row.file.replace('web/', '')}: ${formatLineRanges(row.statements.uncoveredLines)}`)
}
lines.push('')
}
if (diffBranchFailures.length > 0) {
lines.push('Uncovered changed branches:')
for (const row of diffBranchFailures) {
for (const row of diffBranchFailures)
lines.push(`- ${row.file.replace('web/', '')}: ${formatBranchRefs(row.branches.uncoveredBranches)}`)
}
lines.push('')
}
if (ignoredDiffLines.length > 0) {
lines.push('Ignored changed lines via pragma:')
for (const ignoredLine of ignoredDiffLines) {
for (const ignoredLine of ignoredDiffLines)
lines.push(`- ${ignoredLine.file.replace('web/', '')}:${ignoredLine.line} - ${ignoredLine.reason}`)
}
lines.push('')
}
if (invalidIgnorePragmas.length > 0) {
lines.push('Invalid diff coverage ignore pragmas:')
for (const invalidPragma of invalidIgnorePragmas) {
for (const invalidPragma of invalidIgnorePragmas)
lines.push(`- ${invalidPragma.file.replace('web/', '')}:${invalidPragma.line} - ${invalidPragma.reason}`)
}
lines.push('')
}
lines.push(`Changed source files checked: ${changedSourceFiles.length}`)
lines.push(`Changed statement coverage: ${formatDiffPercent(diffTotals.statements)}`)
lines.push(`Changed branch coverage: ${formatDiffPercent(diffTotals.branches)}`)
lines.push('Blocking rules: uncovered changed statements, uncovered changed branches, invalid ignore pragmas.')
return lines
}
function buildSkipSummary(changedExcludedSourceFiles) {
const lines = [
'### app/components Diff Coverage',
'### app/components Pure Diff Coverage',
'',
`Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``,
`Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``,
@@ -357,18 +229,18 @@ function buildSkipSummary(changedExcludedSourceFiles) {
]
if (changedExcludedSourceFiles.length > 0) {
lines.push('Only excluded component modules or type-only files changed, so diff coverage check was skipped.')
lines.push('Only excluded component modules or type-only files changed, so pure diff coverage was skipped.')
lines.push(`Skipped files: ${changedExcludedSourceFiles.length}`)
}
else {
lines.push('No source changes under tracked `web/app/components/`. Diff coverage check skipped.')
lines.push('No tracked source changes under `web/app/components/`. Pure diff coverage skipped.')
}
return lines
}
function getChangedFiles(base, head) {
const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(base, head, DIFF_RANGE_MODE), '--', 'web/app/components', 'web/__tests__'])
const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(base, head, DIFF_RANGE_MODE), '--', APP_COMPONENTS_PREFIX])
return output
.split('\n')
.map(line => line.trim())
@@ -376,127 +248,8 @@ function getChangedFiles(base, head) {
}
function getChangedLineMap(base, head) {
const diff = execGit(['diff', '--unified=0', '--no-color', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(base, head, DIFF_RANGE_MODE), '--', 'web/app/components'])
return parseChangedLineMap(diff, isTrackedComponentSourceFile)
}
function isAnyComponentSourceFile(filePath) {
return filePath.startsWith(APP_COMPONENTS_PREFIX)
&& /\.(?:ts|tsx)$/.test(filePath)
&& !isTestLikePath(filePath)
}
function isTrackedComponentSourceFile(filePath) {
return isAnyComponentSourceFile(filePath)
&& !isExcludedComponentSourceFile(filePath)
}
function isExcludedComponentSourceFile(filePath) {
return isAnyComponentSourceFile(filePath)
&& (
EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath))
|| excludedComponentCoverageFiles.has(filePath)
)
}
function isRelevantTestFile(filePath) {
return filePath.startsWith(SHARED_TEST_PREFIX)
|| (filePath.startsWith(APP_COMPONENTS_PREFIX) && isTestLikePath(filePath) && !isExcludedComponentTestFile(filePath))
}
function isExcludedComponentTestFile(filePath) {
if (!filePath.startsWith(APP_COMPONENTS_PREFIX))
return false
return EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath))
}
function isTestLikePath(filePath) {
return /(?:^|\/)__tests__\//.test(filePath)
|| /(?:^|\/)__mocks__\//.test(filePath)
|| /\.(?:spec|test)\.(?:ts|tsx)$/.test(filePath)
|| /\.stories\.(?:ts|tsx)$/.test(filePath)
|| /\.d\.ts$/.test(filePath)
}
function getCoverageStats(entry) {
const lineHits = getLineHits(entry)
const statementHits = Object.values(entry.s ?? {})
const functionHits = Object.values(entry.f ?? {})
const branchHits = Object.values(entry.b ?? {}).flat()
return {
lines: {
covered: Object.values(lineHits).filter(count => count > 0).length,
total: Object.keys(lineHits).length,
},
statements: {
covered: statementHits.filter(count => count > 0).length,
total: statementHits.length,
},
functions: {
covered: functionHits.filter(count => count > 0).length,
total: functionHits.length,
},
branches: {
covered: branchHits.filter(count => count > 0).length,
total: branchHits.length,
},
}
}
function sumCoverageStats(rows) {
const total = createEmptyCoverageStats()
for (const row of rows)
addCoverageStats(total, row)
return total
}
function mergeCoverageStats(map, moduleName, stats) {
const existing = map.get(moduleName) ?? createEmptyCoverageStats()
addCoverageStats(existing, stats)
map.set(moduleName, existing)
}
function addCoverageStats(target, source) {
for (const metric of ['lines', 'statements', 'functions', 'branches']) {
target[metric].covered += source[metric].covered
target[metric].total += source[metric].total
}
}
function createEmptyCoverageStats() {
return {
lines: { covered: 0, total: 0 },
statements: { covered: 0, total: 0 },
functions: { covered: 0, total: 0 },
branches: { covered: 0, total: 0 },
}
}
function getThresholdFailures(stats, thresholds) {
const failures = []
for (const metric of ['lines', 'statements', 'functions', 'branches']) {
const actual = percentage(stats[metric].covered, stats[metric].total)
const expected = thresholds[metric]
if (actual < expected) {
failures.push({
metric,
actual,
expected,
})
}
}
return failures
}
function getModuleName(filePath) {
const relativePath = filePath.slice(APP_COMPONENTS_PREFIX.length)
if (!relativePath)
return '(root)'
const segments = relativePath.split('/')
return segments.length === 1 ? '(root)' : segments[0]
const diff = execGit(['diff', '--unified=0', '--no-color', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(base, head, DIFF_RANGE_MODE), '--', APP_COMPONENTS_PREFIX])
return parseChangedLineMap(diff, filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles))
}
function formatLineRanges(lines) {
@@ -536,10 +289,6 @@ function percentage(covered, total) {
return (covered / total) * 100
}
function formatPercent(metric) {
return `${percentage(metric.covered, metric.total).toFixed(2)}%`
}
function formatDiffPercent(metric) {
if (metric.total === 0)
return 'n/a'

View File

@@ -0,0 +1,195 @@
import fs from 'node:fs'
import path from 'node:path'
import { getLineHits, normalizeToRepoRelative } from './check-components-diff-coverage-lib.mjs'
import { collectComponentCoverageExcludedFiles } from './component-coverage-filters.mjs'
import { EXCLUDED_COMPONENT_MODULES } from './components-coverage-thresholds.mjs'
export const APP_COMPONENTS_ROOT = 'web/app/components'
export const APP_COMPONENTS_PREFIX = `${APP_COMPONENTS_ROOT}/`
export const APP_COMPONENTS_COVERAGE_PREFIX = 'app/components/'
export const SHARED_TEST_PREFIX = 'web/__tests__/'
export function createComponentCoverageContext(repoRoot) {
const webRoot = path.join(repoRoot, 'web')
const excludedComponentCoverageFiles = new Set(
collectComponentCoverageExcludedFiles(path.join(webRoot, 'app/components'), { pathPrefix: APP_COMPONENTS_ROOT }),
)
return {
excludedComponentCoverageFiles,
repoRoot,
webRoot,
}
}
export function loadTrackedCoverageEntries(coverage, context) {
const coverageEntries = new Map()
for (const [file, entry] of Object.entries(coverage)) {
const repoRelativePath = normalizeToRepoRelative(entry.path ?? file, {
appComponentsCoveragePrefix: APP_COMPONENTS_COVERAGE_PREFIX,
appComponentsPrefix: APP_COMPONENTS_PREFIX,
repoRoot: context.repoRoot,
sharedTestPrefix: SHARED_TEST_PREFIX,
webRoot: context.webRoot,
})
if (!isTrackedComponentSourceFile(repoRelativePath, context.excludedComponentCoverageFiles))
continue
coverageEntries.set(repoRelativePath, entry)
}
return coverageEntries
}
export function collectTrackedComponentSourceFiles(context) {
const trackedFiles = []
walkComponentSourceFiles(path.join(context.webRoot, 'app/components'), (absolutePath) => {
const repoRelativePath = path.relative(context.repoRoot, absolutePath).split(path.sep).join('/')
if (isTrackedComponentSourceFile(repoRelativePath, context.excludedComponentCoverageFiles))
trackedFiles.push(repoRelativePath)
})
trackedFiles.sort((a, b) => a.localeCompare(b))
return trackedFiles
}
export function isTestLikePath(filePath) {
return /(?:^|\/)__tests__\//.test(filePath)
|| /(?:^|\/)__mocks__\//.test(filePath)
|| /\.(?:spec|test)\.(?:ts|tsx)$/.test(filePath)
|| /\.stories\.(?:ts|tsx)$/.test(filePath)
|| /\.d\.ts$/.test(filePath)
}
export function getModuleName(filePath) {
const relativePath = filePath.slice(APP_COMPONENTS_PREFIX.length)
if (!relativePath)
return '(root)'
const segments = relativePath.split('/')
return segments.length === 1 ? '(root)' : segments[0]
}
export function isAnyComponentSourceFile(filePath) {
return filePath.startsWith(APP_COMPONENTS_PREFIX)
&& /\.(?:ts|tsx)$/.test(filePath)
&& !isTestLikePath(filePath)
}
export function isExcludedComponentSourceFile(filePath, excludedComponentCoverageFiles) {
return isAnyComponentSourceFile(filePath)
&& (
EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath))
|| excludedComponentCoverageFiles.has(filePath)
)
}
export function isTrackedComponentSourceFile(filePath, excludedComponentCoverageFiles) {
return isAnyComponentSourceFile(filePath)
&& !isExcludedComponentSourceFile(filePath, excludedComponentCoverageFiles)
}
export function isTrackedComponentTestFile(filePath) {
return filePath.startsWith(APP_COMPONENTS_PREFIX)
&& isTestLikePath(filePath)
&& !EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath))
}
export function isRelevantTestFile(filePath) {
return filePath.startsWith(SHARED_TEST_PREFIX)
|| isTrackedComponentTestFile(filePath)
}
export function isAnyWebTestFile(filePath) {
return filePath.startsWith('web/')
&& isTestLikePath(filePath)
}
export function getCoverageStats(entry) {
const lineHits = getLineHits(entry)
const statementHits = Object.values(entry.s ?? {})
const functionHits = Object.values(entry.f ?? {})
const branchHits = Object.values(entry.b ?? {}).flat()
return {
lines: {
covered: Object.values(lineHits).filter(count => count > 0).length,
total: Object.keys(lineHits).length,
},
statements: {
covered: statementHits.filter(count => count > 0).length,
total: statementHits.length,
},
functions: {
covered: functionHits.filter(count => count > 0).length,
total: functionHits.length,
},
branches: {
covered: branchHits.filter(count => count > 0).length,
total: branchHits.length,
},
}
}
export function sumCoverageStats(rows) {
const total = createEmptyCoverageStats()
for (const row of rows)
addCoverageStats(total, row)
return total
}
export function mergeCoverageStats(map, moduleName, stats) {
const existing = map.get(moduleName) ?? createEmptyCoverageStats()
addCoverageStats(existing, stats)
map.set(moduleName, existing)
}
export function percentage(covered, total) {
if (total === 0)
return 100
return (covered / total) * 100
}
export function formatPercent(metric) {
return `${percentage(metric.covered, metric.total).toFixed(2)}%`
}
function createEmptyCoverageStats() {
return {
lines: { covered: 0, total: 0 },
statements: { covered: 0, total: 0 },
functions: { covered: 0, total: 0 },
branches: { covered: 0, total: 0 },
}
}
function addCoverageStats(target, source) {
for (const metric of ['lines', 'statements', 'functions', 'branches']) {
target[metric].covered += source[metric].covered
target[metric].total += source[metric].total
}
}
function walkComponentSourceFiles(currentDir, onFile) {
if (!fs.existsSync(currentDir))
return
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
for (const entry of entries) {
const entryPath = path.join(currentDir, entry.name)
if (entry.isDirectory()) {
if (entry.name === '__tests__' || entry.name === '__mocks__')
continue
walkComponentSourceFiles(entryPath, onFile)
continue
}
if (!/\.(?:ts|tsx)$/.test(entry.name) || isTestLikePath(entry.name))
continue
onFile(entryPath)
}
}

View File

@@ -0,0 +1,165 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { COMPONENT_COVERAGE_EXCLUDE_LABEL } from './component-coverage-filters.mjs'
import {
collectTrackedComponentSourceFiles,
createComponentCoverageContext,
formatPercent,
getCoverageStats,
getModuleName,
loadTrackedCoverageEntries,
mergeCoverageStats,
percentage,
sumCoverageStats,
} from './components-coverage-common.mjs'
import {
COMPONENTS_GLOBAL_THRESHOLDS,
EXCLUDED_COMPONENT_MODULES,
getComponentModuleThreshold,
} from './components-coverage-thresholds.mjs'
const EXCLUDED_MODULES_LABEL = [...EXCLUDED_COMPONENT_MODULES].sort().join(', ')
const repoRoot = repoRootFromCwd()
const context = createComponentCoverageContext(repoRoot)
const coverageFinalPath = path.join(context.webRoot, 'coverage', 'coverage-final.json')
if (!fs.existsSync(coverageFinalPath)) {
console.error(`Coverage report not found at ${coverageFinalPath}`)
process.exit(1)
}
const coverage = JSON.parse(fs.readFileSync(coverageFinalPath, 'utf8'))
const trackedSourceFiles = collectTrackedComponentSourceFiles(context)
const coverageEntries = loadTrackedCoverageEntries(coverage, context)
const fileCoverageRows = []
const moduleCoverageMap = new Map()
for (const [file, entry] of coverageEntries.entries()) {
const stats = getCoverageStats(entry)
const moduleName = getModuleName(file)
fileCoverageRows.push({ file, moduleName, ...stats })
mergeCoverageStats(moduleCoverageMap, moduleName, stats)
}
const overallCoverage = sumCoverageStats(fileCoverageRows)
const overallTargetGaps = getTargetGaps(overallCoverage, COMPONENTS_GLOBAL_THRESHOLDS)
const moduleCoverageRows = [...moduleCoverageMap.entries()]
.map(([moduleName, stats]) => ({
moduleName,
stats,
targets: getComponentModuleThreshold(moduleName),
}))
.map(row => ({
...row,
targetGaps: row.targets ? getTargetGaps(row.stats, row.targets) : [],
}))
.sort((a, b) => {
const aWorst = Math.min(...a.targetGaps.map(gap => gap.delta), Number.POSITIVE_INFINITY)
const bWorst = Math.min(...b.targetGaps.map(gap => gap.delta), Number.POSITIVE_INFINITY)
return aWorst - bWorst || a.moduleName.localeCompare(b.moduleName)
})
appendSummary(buildSummary({
coverageEntriesCount: coverageEntries.size,
moduleCoverageRows,
overallCoverage,
overallTargetGaps,
trackedSourceFilesCount: trackedSourceFiles.length,
}))
function buildSummary({
coverageEntriesCount,
moduleCoverageRows,
overallCoverage,
overallTargetGaps,
trackedSourceFilesCount,
}) {
const lines = [
'### app/components Baseline Coverage',
'',
`Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``,
`Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``,
'',
`Coverage entries: ${coverageEntriesCount}/${trackedSourceFilesCount} tracked source files`,
'',
'| Metric | Current | Target | Delta |',
'|---|---:|---:|---:|',
`| Lines | ${formatPercent(overallCoverage.lines)} | ${COMPONENTS_GLOBAL_THRESHOLDS.lines}% | ${formatDelta(overallCoverage.lines, COMPONENTS_GLOBAL_THRESHOLDS.lines)} |`,
`| Statements | ${formatPercent(overallCoverage.statements)} | ${COMPONENTS_GLOBAL_THRESHOLDS.statements}% | ${formatDelta(overallCoverage.statements, COMPONENTS_GLOBAL_THRESHOLDS.statements)} |`,
`| Functions | ${formatPercent(overallCoverage.functions)} | ${COMPONENTS_GLOBAL_THRESHOLDS.functions}% | ${formatDelta(overallCoverage.functions, COMPONENTS_GLOBAL_THRESHOLDS.functions)} |`,
`| Branches | ${formatPercent(overallCoverage.branches)} | ${COMPONENTS_GLOBAL_THRESHOLDS.branches}% | ${formatDelta(overallCoverage.branches, COMPONENTS_GLOBAL_THRESHOLDS.branches)} |`,
'',
]
if (coverageEntriesCount !== trackedSourceFilesCount) {
lines.push('Warning: coverage report did not include every tracked component source file. CI should set `VITEST_COVERAGE_SCOPE=app-components` before collecting coverage.')
lines.push('')
}
if (overallTargetGaps.length > 0) {
lines.push('Below baseline targets:')
for (const gap of overallTargetGaps)
lines.push(`- overall ${gap.metric}: ${gap.actual.toFixed(2)}% < ${gap.target}%`)
lines.push('')
}
lines.push('<details><summary>Module baseline coverage</summary>')
lines.push('')
lines.push('| Module | Lines | Statements | Functions | Branches | Targets | Status |')
lines.push('|---|---:|---:|---:|---:|---|---|')
for (const row of moduleCoverageRows) {
const targetsLabel = row.targets
? `L${row.targets.lines}/S${row.targets.statements}/F${row.targets.functions}/B${row.targets.branches}`
: 'n/a'
const status = row.targets
? (row.targetGaps.length > 0 ? 'below-target' : 'at-target')
: 'unconfigured'
lines.push(`| ${row.moduleName} | ${percentage(row.stats.lines.covered, row.stats.lines.total).toFixed(2)}% | ${percentage(row.stats.statements.covered, row.stats.statements.total).toFixed(2)}% | ${percentage(row.stats.functions.covered, row.stats.functions.total).toFixed(2)}% | ${percentage(row.stats.branches.covered, row.stats.branches.total).toFixed(2)}% | ${targetsLabel} | ${status} |`)
}
lines.push('</details>')
lines.push('')
lines.push('Report only: baseline targets no longer gate CI. The blocking rule is the pure diff coverage step.')
return lines
}
function getTargetGaps(stats, targets) {
const gaps = []
for (const metric of ['lines', 'statements', 'functions', 'branches']) {
const actual = percentage(stats[metric].covered, stats[metric].total)
const target = targets[metric]
const delta = actual - target
if (delta < 0) {
gaps.push({
actual,
delta,
metric,
target,
})
}
}
return gaps
}
function formatDelta(metric, target) {
const actual = percentage(metric.covered, metric.total)
const delta = actual - target
const sign = delta >= 0 ? '+' : ''
return `${sign}${delta.toFixed(2)}%`
}
function appendSummary(lines) {
const content = `${lines.join('\n')}\n`
if (process.env.GITHUB_STEP_SUMMARY)
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, content)
console.log(content)
}
function repoRootFromCwd() {
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd: process.cwd(),
encoding: 'utf8',
}).trim()
}

View File

@@ -0,0 +1,129 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import {
buildGitDiffRevisionArgs,
} from './check-components-diff-coverage-lib.mjs'
import {
createComponentCoverageContext,
isAnyWebTestFile,
isRelevantTestFile,
isTrackedComponentSourceFile,
} from './components-coverage-common.mjs'
const DIFF_RANGE_MODE = process.env.DIFF_RANGE_MODE === 'exact' ? 'exact' : 'merge-base'
const repoRoot = repoRootFromCwd()
const context = createComponentCoverageContext(repoRoot)
const baseSha = process.env.BASE_SHA?.trim()
const headSha = process.env.HEAD_SHA?.trim() || 'HEAD'
if (!baseSha || /^0+$/.test(baseSha)) {
appendSummary([
'### app/components Test Touch',
'',
'Skipped test-touch report because `BASE_SHA` was not available.',
])
process.exit(0)
}
const changedFiles = getChangedFiles(baseSha, headSha)
const changedSourceFiles = changedFiles.filter(filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles))
if (changedSourceFiles.length === 0) {
appendSummary([
'### app/components Test Touch',
'',
'No tracked source changes under `web/app/components/`. Test-touch report skipped.',
])
process.exit(0)
}
const changedRelevantTestFiles = changedFiles.filter(isRelevantTestFile)
const changedOtherWebTestFiles = changedFiles.filter(filePath => isAnyWebTestFile(filePath) && !isRelevantTestFile(filePath))
const totalChangedWebTests = [...new Set([...changedRelevantTestFiles, ...changedOtherWebTestFiles])]
appendSummary(buildSummary({
changedOtherWebTestFiles,
changedRelevantTestFiles,
changedSourceFiles,
totalChangedWebTests,
}))
function buildSummary({
changedOtherWebTestFiles,
changedRelevantTestFiles,
changedSourceFiles,
totalChangedWebTests,
}) {
const lines = [
'### app/components Test Touch',
'',
`Compared \`${baseSha.slice(0, 12)}\` -> \`${headSha.slice(0, 12)}\``,
`Diff range mode: \`${DIFF_RANGE_MODE}\``,
'',
`Tracked source files changed: ${changedSourceFiles.length}`,
`Component-local or shared integration tests changed: ${changedRelevantTestFiles.length}`,
`Other web tests changed: ${changedOtherWebTestFiles.length}`,
`Total changed web tests: ${totalChangedWebTests.length}`,
'',
]
if (totalChangedWebTests.length === 0) {
lines.push('Warning: no frontend test files changed alongside tracked component source changes.')
lines.push('')
}
if (changedRelevantTestFiles.length > 0) {
lines.push('<details><summary>Changed component-local or shared tests</summary>')
lines.push('')
for (const filePath of changedRelevantTestFiles.slice(0, 40))
lines.push(`- ${filePath.replace('web/', '')}`)
if (changedRelevantTestFiles.length > 40)
lines.push(`- ... ${changedRelevantTestFiles.length - 40} more`)
lines.push('</details>')
lines.push('')
}
if (changedOtherWebTestFiles.length > 0) {
lines.push('<details><summary>Changed other web tests</summary>')
lines.push('')
for (const filePath of changedOtherWebTestFiles.slice(0, 40))
lines.push(`- ${filePath.replace('web/', '')}`)
if (changedOtherWebTestFiles.length > 40)
lines.push(`- ... ${changedOtherWebTestFiles.length - 40} more`)
lines.push('</details>')
lines.push('')
}
lines.push('Report only: test-touch is now advisory and no longer blocks the diff coverage gate.')
return lines
}
function getChangedFiles(base, head) {
const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(base, head, DIFF_RANGE_MODE), '--', 'web'])
return output
.split('\n')
.map(line => line.trim())
.filter(Boolean)
}
function appendSummary(lines) {
const content = `${lines.join('\n')}\n`
if (process.env.GITHUB_STEP_SUMMARY)
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, content)
console.log(content)
}
function execGit(args) {
return execFileSync('git', args, {
cwd: repoRoot,
encoding: 'utf8',
})
}
function repoRootFromCwd() {
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd: process.cwd(),
encoding: 'utf8',
}).trim()
}

215
web/service/use-snippets.ts Normal file
View File

@@ -0,0 +1,215 @@
import type { Node } from '@/app/components/workflow/types'
import type { SnippetDetailPayload, SnippetInputField, SnippetListItem } from '@/models/snippet'
import { useQuery } from '@tanstack/react-query'
import codeDefault from '@/app/components/workflow/nodes/code/default'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import httpDefault from '@/app/components/workflow/nodes/http/default'
import { Method } from '@/app/components/workflow/nodes/http/types'
import llmDefault from '@/app/components/workflow/nodes/llm/default'
import questionClassifierDefault from '@/app/components/workflow/nodes/question-classifier/default'
import { BlockEnum, PromptRole } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
import { AppModeEnum } from '@/types/app'
const NAME_SPACE = 'snippets'
export const getSnippetListMock = (): SnippetListItem[] => ([
{
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.',
author: 'Evan',
updatedAt: 'Updated 2h ago',
usage: 'Used 19 times',
icon: '🪄',
iconBackground: '#E0EAFF',
status: 'Draft',
},
])
const getSnippetInputFieldsMock = (): SnippetInputField[] => ([
{
type: PipelineInputVarType.textInput,
label: 'Blog URL',
variable: 'blog_url',
required: true,
placeholder: 'Paste a source article URL',
options: [],
max_length: 256,
},
{
type: PipelineInputVarType.textInput,
label: 'Target Platforms',
variable: 'platforms',
required: true,
placeholder: 'X, LinkedIn, Instagram',
options: [],
max_length: 128,
},
{
type: PipelineInputVarType.textInput,
label: 'Tone',
variable: 'tone',
required: false,
placeholder: 'Concise and executive-ready',
options: [],
max_length: 48,
},
{
type: PipelineInputVarType.textInput,
label: 'Max Length',
variable: 'max_length',
required: false,
placeholder: 'Set an ideal output length',
options: [],
max_length: 48,
},
])
const getSnippetGraphMock = (): SnippetDetailPayload['graph'] => ({
viewport: { x: 120, y: 30, zoom: 0.9 },
nodes: [
{
id: 'question-classifier',
position: { x: 280, y: 208 },
data: {
...questionClassifierDefault.defaultValue,
title: 'Question Classifier',
desc: 'After-sales related questions',
type: BlockEnum.QuestionClassifier,
query_variable_selector: ['sys', 'query'],
model: {
provider: 'openai',
name: 'gpt-4o',
mode: AppModeEnum.CHAT,
completion_params: {
temperature: 0.2,
},
},
classes: [
{
id: '1',
name: 'HTTP Request',
},
{
id: '2',
name: 'LLM',
},
{
id: '3',
name: 'Code',
},
],
} as unknown as Node['data'],
},
{
id: 'http-request',
position: { x: 670, y: 72 },
data: {
...httpDefault.defaultValue,
title: 'HTTP Request',
desc: 'POST https://api.example.com/content/rewrite',
type: BlockEnum.HttpRequest,
method: Method.post,
url: 'https://api.example.com/content/rewrite',
headers: 'Content-Type: application/json',
} as unknown as Node['data'],
},
{
id: 'llm',
position: { x: 670, y: 248 },
data: {
...llmDefault.defaultValue,
title: 'LLM',
desc: 'GPT-4o',
type: BlockEnum.LLM,
model: {
provider: 'openai',
name: 'gpt-4o',
mode: AppModeEnum.CHAT,
completion_params: {
temperature: 0.7,
},
},
prompt_template: [{
role: PromptRole.system,
text: 'Rewrite the content with the requested tone.',
}],
} as unknown as Node['data'],
},
{
id: 'code',
position: { x: 670, y: 424 },
data: {
...codeDefault.defaultValue,
title: 'Code',
desc: 'Python',
type: BlockEnum.Code,
code_language: CodeLanguage.python3,
code: 'def main(text: str) -> dict:\n return {"content": text.strip()}',
} as unknown as Node['data'],
},
],
edges: [
{
id: 'edge-question-http',
source: 'question-classifier',
sourceHandle: '1',
target: 'http-request',
targetHandle: 'target',
},
{
id: 'edge-question-llm',
source: 'question-classifier',
sourceHandle: '2',
target: 'llm',
targetHandle: 'target',
},
{
id: 'edge-question-code',
source: 'question-classifier',
sourceHandle: '3',
target: 'code',
targetHandle: 'target',
},
],
})
const getSnippetDetailMock = (snippetId: string): SnippetDetailPayload | null => {
const snippet = getSnippetListMock().find(item => item.id === snippetId)
if (!snippet)
return null
const inputFields = getSnippetInputFieldsMock()
return {
snippet,
graph: getSnippetGraphMock(),
inputFields,
uiMeta: {
inputFieldCount: inputFields.length,
checklistCount: 2,
autoSavedAt: 'Auto-saved · a few seconds ago',
},
}
}
export const useSnippetDetail = (snippetId: string) => {
return useQuery({
queryKey: [NAME_SPACE, 'detail', snippetId],
queryFn: async () => getSnippetDetailMock(snippetId),
enabled: !!snippetId,
})
}
export const publishSnippet = async (_snippetId: string) => {
return Promise.resolve()
}
export const runSnippet = async (_snippetId: string) => {
return Promise.resolve()
}
export const updateSnippetInputFields = async (_snippetId: string, _fields: SnippetInputField[]) => {
return Promise.resolve()
}