mirror of
https://github.com/langgenius/dify.git
synced 2026-03-17 21:37:03 +00:00
Compare commits
13 Commits
fix-kpa-is
...
feat/evalu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d3738d225 | ||
|
|
f198f5b0ab | ||
|
|
49e0e1b939 | ||
|
|
f886f11094 | ||
|
|
fa82a0f708 | ||
|
|
0a3275fbe8 | ||
|
|
dd0dee739d | ||
|
|
4d19914fcb | ||
|
|
887c7710e9 | ||
|
|
7a722773c7 | ||
|
|
a763aff58b | ||
|
|
c1011f4e5c | ||
|
|
f7afa103a5 |
324
.github/workflows/web-tests.yml
vendored
324
.github/workflows/web-tests.yml
vendored
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
6
api/uv.lock
generated
6
api/uv.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
})
|
||||
|
||||
72
web/__tests__/components-coverage-common.test.ts
Normal file
72
web/__tests__/components-coverage-common.test.ts
Normal 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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}/`)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal 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
|
||||
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Apps from '@/app/components/apps'
|
||||
|
||||
const SnippetsPage = () => {
|
||||
return <Apps pageType="snippets" />
|
||||
}
|
||||
|
||||
export default SnippetsPage
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()}
|
||||
|
||||
53
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
53
web/app/components/app-sidebar/snippet-info/index.tsx
Normal 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)
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
15
web/app/components/apps/app-type-filter-shared.ts
Normal file
15
web/app/components/apps/app-type-filter-shared.ts
Normal 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' })
|
||||
71
web/app/components/apps/app-type-filter.tsx
Normal file
71
web/app/components/apps/app-type-filter.tsx
Normal 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
|
||||
128
web/app/components/apps/creators-filter.tsx
Normal file
128
web/app/components/apps/creators-filter.tsx
Normal 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
|
||||
@@ -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 || ''}
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
112
web/app/components/evaluation/__tests__/index.spec.tsx
Normal file
112
web/app/components/evaluation/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
96
web/app/components/evaluation/__tests__/store.spec.ts
Normal file
96
web/app/components/evaluation/__tests__/store.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
1017
web/app/components/evaluation/index.tsx
Normal file
1017
web/app/components/evaluation/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
184
web/app/components/evaluation/mock.ts
Normal file
184
web/app/components/evaluation/mock.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
635
web/app/components/evaluation/store.ts
Normal file
635
web/app/components/evaluation/store.ts
Normal 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)
|
||||
}
|
||||
117
web/app/components/evaluation/types.ts
Normal file
117
web/app/components/evaluation/types.ts
Normal 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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
|
||||
171
web/app/components/snippets/__tests__/index.spec.tsx
Normal file
171
web/app/components/snippets/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
119
web/app/components/snippets/components/panel/index.tsx
Normal file
119
web/app/components/snippets/components/panel/index.tsx
Normal 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)
|
||||
29
web/app/components/snippets/components/publish-menu.tsx
Normal file
29
web/app/components/snippets/components/publish-menu.tsx
Normal 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
|
||||
106
web/app/components/snippets/components/snippet-children.tsx
Normal file
106
web/app/components/snippets/components/snippet-children.tsx
Normal 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
|
||||
@@ -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
|
||||
198
web/app/components/snippets/components/snippet-main.tsx
Normal file
198
web/app/components/snippets/components/snippet-main.tsx
Normal 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
|
||||
111
web/app/components/snippets/components/workflow-panel.tsx
Normal file
111
web/app/components/snippets/components/workflow-panel.tsx
Normal 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)
|
||||
5
web/app/components/snippets/hooks/use-snippet-init.ts
Normal file
5
web/app/components/snippets/hooks/use-snippet-init.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useSnippetDetail } from '@/service/use-snippets'
|
||||
|
||||
export const useSnippetInit = (snippetId: string) => {
|
||||
return useSnippetDetail(snippetId)
|
||||
}
|
||||
86
web/app/components/snippets/index.tsx
Normal file
86
web/app/components/snippets/index.tsx
Normal 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
|
||||
44
web/app/components/snippets/store/index.ts
Normal file
44
web/app/components/snippets/store/index.ts
Normal 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),
|
||||
}))
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
247
web/app/components/workflow/block-selector/snippets.tsx
Normal file
247
web/app/components/workflow/block-selector/snippets.tsx
Normal 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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum TabsEnum {
|
||||
Blocks = 'blocks',
|
||||
Tools = 'tools',
|
||||
Sources = 'sources',
|
||||
Snippets = 'snippets',
|
||||
}
|
||||
|
||||
export enum ToolTypeEnum {
|
||||
|
||||
178
web/app/components/workflow/create-snippet-dialog.tsx
Normal file
178
web/app/components/workflow/create-snippet-dialog.tsx
Normal 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
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
73
web/i18n/en-US/evaluation.json
Normal file
73
web/i18n/en-US/evaluation.json
Normal 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"
|
||||
}
|
||||
16
web/i18n/en-US/snippet.json
Normal file
16
web/i18n/en-US/snippet.json
Normal 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"
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "流水线",
|
||||
|
||||
73
web/i18n/zh-Hans/evaluation.json
Normal file
73
web/i18n/zh-Hans/evaluation.json
Normal 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": "评测"
|
||||
}
|
||||
16
web/i18n/zh-Hans/snippet.json
Normal file
16
web/i18n/zh-Hans/snippet.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"create": "创建 Snippet",
|
||||
"inputFieldButton": "输入字段",
|
||||
"notFoundDescription": "未找到对应的 snippet 静态数据。",
|
||||
"notFoundTitle": "未找到 Snippet",
|
||||
"panelDescription": "定义允许 snippet 从其他节点接收数据的输入字段。",
|
||||
"panelPrimaryGroup": "核心输入",
|
||||
"panelSecondaryGroup": "可选输入",
|
||||
"panelTitle": "输入字段",
|
||||
"publishButton": "发布",
|
||||
"publishMenuCurrentDraft": "当前草稿未发布",
|
||||
"sectionEvaluation": "评测",
|
||||
"sectionOrchestrate": "编排",
|
||||
"testRunButton": "测试运行",
|
||||
"variableInspect": "变量查看"
|
||||
}
|
||||
@@ -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
50
web/models/snippet.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
195
web/scripts/components-coverage-common.mjs
Normal file
195
web/scripts/components-coverage-common.mjs
Normal 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)
|
||||
}
|
||||
}
|
||||
165
web/scripts/report-components-coverage-baseline.mjs
Normal file
165
web/scripts/report-components-coverage-baseline.mjs
Normal 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()
|
||||
}
|
||||
129
web/scripts/report-components-test-touch.mjs
Normal file
129
web/scripts/report-components-test-touch.mjs
Normal 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
215
web/service/use-snippets.ts
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user