From 34fbcc945793e7864bcb5861baa3d777d342bce3 Mon Sep 17 00:00:00 2001 From: Guangdong Liu Date: Mon, 20 Oct 2025 14:12:39 +0800 Subject: [PATCH 01/14] fix: ensure document re-querying in indexing process for consistency (#27077) --- api/core/indexing_runner.py | 121 ++++++++++++++++++++---------------- 1 file changed, 69 insertions(+), 52 deletions(-) diff --git a/api/core/indexing_runner.py b/api/core/indexing_runner.py index 7822ed4268..c430fba0b9 100644 --- a/api/core/indexing_runner.py +++ b/api/core/indexing_runner.py @@ -49,62 +49,80 @@ class IndexingRunner: self.storage = storage self.model_manager = ModelManager() + def _handle_indexing_error(self, document_id: str, error: Exception) -> None: + """Handle indexing errors by updating document status.""" + logger.exception("consume document failed") + document = db.session.get(DatasetDocument, document_id) + if document: + document.indexing_status = "error" + error_message = getattr(error, "description", str(error)) + document.error = str(error_message) + document.stopped_at = naive_utc_now() + db.session.commit() + def run(self, dataset_documents: list[DatasetDocument]): """Run the indexing process.""" for dataset_document in dataset_documents: + document_id = dataset_document.id try: + # Re-query the document to ensure it's bound to the current session + requeried_document = db.session.get(DatasetDocument, document_id) + if not requeried_document: + logger.warning("Document not found, skipping document id: %s", document_id) + continue + # get dataset - dataset = db.session.query(Dataset).filter_by(id=dataset_document.dataset_id).first() + dataset = db.session.query(Dataset).filter_by(id=requeried_document.dataset_id).first() if not dataset: raise ValueError("no dataset found") # get the process rule stmt = select(DatasetProcessRule).where( - DatasetProcessRule.id == dataset_document.dataset_process_rule_id + DatasetProcessRule.id == requeried_document.dataset_process_rule_id ) processing_rule = db.session.scalar(stmt) if not processing_rule: raise ValueError("no process rule found") - index_type = dataset_document.doc_form + index_type = requeried_document.doc_form index_processor = IndexProcessorFactory(index_type).init_index_processor() # extract - text_docs = self._extract(index_processor, dataset_document, processing_rule.to_dict()) + text_docs = self._extract(index_processor, requeried_document, processing_rule.to_dict()) # transform documents = self._transform( - index_processor, dataset, text_docs, dataset_document.doc_language, processing_rule.to_dict() + index_processor, dataset, text_docs, requeried_document.doc_language, processing_rule.to_dict() ) # save segment - self._load_segments(dataset, dataset_document, documents) + self._load_segments(dataset, requeried_document, documents) # load self._load( index_processor=index_processor, dataset=dataset, - dataset_document=dataset_document, + dataset_document=requeried_document, documents=documents, ) except DocumentIsPausedError: - raise DocumentIsPausedError(f"Document paused, document id: {dataset_document.id}") + raise DocumentIsPausedError(f"Document paused, document id: {document_id}") except ProviderTokenNotInitError as e: - dataset_document.indexing_status = "error" - dataset_document.error = str(e.description) - dataset_document.stopped_at = naive_utc_now() - db.session.commit() + self._handle_indexing_error(document_id, e) except ObjectDeletedError: - logger.warning("Document deleted, document id: %s", dataset_document.id) + logger.warning("Document deleted, document id: %s", document_id) except Exception as e: - logger.exception("consume document failed") - dataset_document.indexing_status = "error" - dataset_document.error = str(e) - dataset_document.stopped_at = naive_utc_now() - db.session.commit() + self._handle_indexing_error(document_id, e) def run_in_splitting_status(self, dataset_document: DatasetDocument): """Run the indexing process when the index_status is splitting.""" + document_id = dataset_document.id try: + # Re-query the document to ensure it's bound to the current session + requeried_document = db.session.get(DatasetDocument, document_id) + if not requeried_document: + logger.warning("Document not found: %s", document_id) + return + # get dataset - dataset = db.session.query(Dataset).filter_by(id=dataset_document.dataset_id).first() + dataset = db.session.query(Dataset).filter_by(id=requeried_document.dataset_id).first() if not dataset: raise ValueError("no dataset found") @@ -112,57 +130,60 @@ class IndexingRunner: # get exist document_segment list and delete document_segments = ( db.session.query(DocumentSegment) - .filter_by(dataset_id=dataset.id, document_id=dataset_document.id) + .filter_by(dataset_id=dataset.id, document_id=requeried_document.id) .all() ) for document_segment in document_segments: db.session.delete(document_segment) - if dataset_document.doc_form == IndexType.PARENT_CHILD_INDEX: + if requeried_document.doc_form == IndexType.PARENT_CHILD_INDEX: # delete child chunks db.session.query(ChildChunk).where(ChildChunk.segment_id == document_segment.id).delete() db.session.commit() # get the process rule - stmt = select(DatasetProcessRule).where(DatasetProcessRule.id == dataset_document.dataset_process_rule_id) + stmt = select(DatasetProcessRule).where(DatasetProcessRule.id == requeried_document.dataset_process_rule_id) processing_rule = db.session.scalar(stmt) if not processing_rule: raise ValueError("no process rule found") - index_type = dataset_document.doc_form + index_type = requeried_document.doc_form index_processor = IndexProcessorFactory(index_type).init_index_processor() # extract - text_docs = self._extract(index_processor, dataset_document, processing_rule.to_dict()) + text_docs = self._extract(index_processor, requeried_document, processing_rule.to_dict()) # transform documents = self._transform( - index_processor, dataset, text_docs, dataset_document.doc_language, processing_rule.to_dict() + index_processor, dataset, text_docs, requeried_document.doc_language, processing_rule.to_dict() ) # save segment - self._load_segments(dataset, dataset_document, documents) + self._load_segments(dataset, requeried_document, documents) # load self._load( - index_processor=index_processor, dataset=dataset, dataset_document=dataset_document, documents=documents + index_processor=index_processor, + dataset=dataset, + dataset_document=requeried_document, + documents=documents, ) except DocumentIsPausedError: - raise DocumentIsPausedError(f"Document paused, document id: {dataset_document.id}") + raise DocumentIsPausedError(f"Document paused, document id: {document_id}") except ProviderTokenNotInitError as e: - dataset_document.indexing_status = "error" - dataset_document.error = str(e.description) - dataset_document.stopped_at = naive_utc_now() - db.session.commit() + self._handle_indexing_error(document_id, e) except Exception as e: - logger.exception("consume document failed") - dataset_document.indexing_status = "error" - dataset_document.error = str(e) - dataset_document.stopped_at = naive_utc_now() - db.session.commit() + self._handle_indexing_error(document_id, e) def run_in_indexing_status(self, dataset_document: DatasetDocument): """Run the indexing process when the index_status is indexing.""" + document_id = dataset_document.id try: + # Re-query the document to ensure it's bound to the current session + requeried_document = db.session.get(DatasetDocument, document_id) + if not requeried_document: + logger.warning("Document not found: %s", document_id) + return + # get dataset - dataset = db.session.query(Dataset).filter_by(id=dataset_document.dataset_id).first() + dataset = db.session.query(Dataset).filter_by(id=requeried_document.dataset_id).first() if not dataset: raise ValueError("no dataset found") @@ -170,7 +191,7 @@ class IndexingRunner: # get exist document_segment list and delete document_segments = ( db.session.query(DocumentSegment) - .filter_by(dataset_id=dataset.id, document_id=dataset_document.id) + .filter_by(dataset_id=dataset.id, document_id=requeried_document.id) .all() ) @@ -188,7 +209,7 @@ class IndexingRunner: "dataset_id": document_segment.dataset_id, }, ) - if dataset_document.doc_form == IndexType.PARENT_CHILD_INDEX: + if requeried_document.doc_form == IndexType.PARENT_CHILD_INDEX: child_chunks = document_segment.get_child_chunks() if child_chunks: child_documents = [] @@ -206,24 +227,20 @@ class IndexingRunner: document.children = child_documents documents.append(document) # build index - index_type = dataset_document.doc_form + index_type = requeried_document.doc_form index_processor = IndexProcessorFactory(index_type).init_index_processor() self._load( - index_processor=index_processor, dataset=dataset, dataset_document=dataset_document, documents=documents + index_processor=index_processor, + dataset=dataset, + dataset_document=requeried_document, + documents=documents, ) except DocumentIsPausedError: - raise DocumentIsPausedError(f"Document paused, document id: {dataset_document.id}") + raise DocumentIsPausedError(f"Document paused, document id: {document_id}") except ProviderTokenNotInitError as e: - dataset_document.indexing_status = "error" - dataset_document.error = str(e.description) - dataset_document.stopped_at = naive_utc_now() - db.session.commit() + self._handle_indexing_error(document_id, e) except Exception as e: - logger.exception("consume document failed") - dataset_document.indexing_status = "error" - dataset_document.error = str(e) - dataset_document.stopped_at = naive_utc_now() - db.session.commit() + self._handle_indexing_error(document_id, e) def indexing_estimate( self, From 9dd3dcff2b59ee386746f245c17e82a054ee8cee Mon Sep 17 00:00:00 2001 From: GuanMu Date: Mon, 20 Oct 2025 15:35:13 +0800 Subject: [PATCH 02/14] Fix type error 5 (#27139) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- web/__tests__/navigation-utils.test.ts | 3 +- web/__tests__/real-browser-flicker.test.tsx | 249 ++++++++++++-------- web/__tests__/unified-tags-logic.test.ts | 32 ++- 3 files changed, 167 insertions(+), 117 deletions(-) diff --git a/web/__tests__/navigation-utils.test.ts b/web/__tests__/navigation-utils.test.ts index 9a388505d6..fa4986e63d 100644 --- a/web/__tests__/navigation-utils.test.ts +++ b/web/__tests__/navigation-utils.test.ts @@ -160,8 +160,7 @@ describe('Navigation Utilities', () => { page: 1, limit: '', keyword: 'test', - empty: null, - undefined, + filter: '', }) expect(path).toBe('/datasets/123/documents?page=1&keyword=test') diff --git a/web/__tests__/real-browser-flicker.test.tsx b/web/__tests__/real-browser-flicker.test.tsx index f71e8de515..0a0ea0c062 100644 --- a/web/__tests__/real-browser-flicker.test.tsx +++ b/web/__tests__/real-browser-flicker.test.tsx @@ -39,28 +39,38 @@ const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = fa const isDarkQuery = DARK_MODE_MEDIA_QUERY.test(query) const matches = isDarkQuery ? systemPrefersDark : false + const handleAddListener = (listener: (event: MediaQueryListEvent) => void) => { + listeners.add(listener) + } + + const handleRemoveListener = (listener: (event: MediaQueryListEvent) => void) => { + listeners.delete(listener) + } + + const handleAddEventListener = (_event: string, listener: EventListener) => { + if (typeof listener === 'function') + listeners.add(listener as (event: MediaQueryListEvent) => void) + } + + const handleRemoveEventListener = (_event: string, listener: EventListener) => { + if (typeof listener === 'function') + listeners.delete(listener as (event: MediaQueryListEvent) => void) + } + + const handleDispatchEvent = (event: Event) => { + listeners.forEach(listener => listener(event as MediaQueryListEvent)) + return true + } + const mediaQueryList: MediaQueryList = { matches, media: query, onchange: null, - addListener: (listener: MediaQueryListListener) => { - listeners.add(listener) - }, - removeListener: (listener: MediaQueryListListener) => { - listeners.delete(listener) - }, - addEventListener: (_event, listener: EventListener) => { - if (typeof listener === 'function') - listeners.add(listener as MediaQueryListListener) - }, - removeEventListener: (_event, listener: EventListener) => { - if (typeof listener === 'function') - listeners.delete(listener as MediaQueryListListener) - }, - dispatchEvent: (event: Event) => { - listeners.forEach(listener => listener(event as MediaQueryListEvent)) - return true - }, + addListener: handleAddListener, + removeListener: handleRemoveListener, + addEventListener: handleAddEventListener, + removeEventListener: handleRemoveEventListener, + dispatchEvent: handleDispatchEvent, } return mediaQueryList @@ -69,6 +79,121 @@ const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = fa jest.spyOn(window, 'matchMedia').mockImplementation(mockMatchMedia) } +// Helper function to create timing page component +const createTimingPageComponent = ( + timingData: Array<{ phase: string; timestamp: number; styles: { backgroundColor: string; color: string } }>, +) => { + const recordTiming = (phase: string, styles: { backgroundColor: string; color: string }) => { + timingData.push({ + phase, + timestamp: performance.now(), + styles, + }) + } + + const TimingPageComponent = () => { + const [mounted, setMounted] = useState(false) + const { theme } = useTheme() + const isDark = mounted ? theme === 'dark' : false + + const currentStyles = { + backgroundColor: isDark ? '#1f2937' : '#ffffff', + color: isDark ? '#ffffff' : '#000000', + } + + recordTiming(mounted ? 'CSR' : 'Initial', currentStyles) + + useEffect(() => { + setMounted(true) + }, []) + + return ( +
+
+ Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual: {isDark ? 'dark' : 'light'} +
+
+ ) + } + + return TimingPageComponent +} + +// Helper function to create CSS test component +const createCSSTestComponent = ( + cssStates: Array<{ className: string; timestamp: number }>, +) => { + const recordCSSState = (className: string) => { + cssStates.push({ + className, + timestamp: performance.now(), + }) + } + + const CSSTestComponent = () => { + const [mounted, setMounted] = useState(false) + const { theme } = useTheme() + const isDark = mounted ? theme === 'dark' : false + + const className = `min-h-screen ${isDark ? 'bg-gray-900 text-white' : 'bg-white text-black'}` + + recordCSSState(className) + + useEffect(() => { + setMounted(true) + }, []) + + return ( +
+
Classes: {className}
+
+ ) + } + + return CSSTestComponent +} + +// Helper function to create performance test component +const createPerformanceTestComponent = ( + performanceMarks: Array<{ event: string; timestamp: number }>, +) => { + const recordPerformanceMark = (event: string) => { + performanceMarks.push({ event, timestamp: performance.now() }) + } + + const PerformanceTestComponent = () => { + const [mounted, setMounted] = useState(false) + const { theme } = useTheme() + + recordPerformanceMark('component-render') + + useEffect(() => { + recordPerformanceMark('mount-start') + setMounted(true) + recordPerformanceMark('mount-complete') + }, []) + + useEffect(() => { + if (theme) + recordPerformanceMark('theme-available') + }, [theme]) + + return ( +
+ Mounted: {mounted.toString()} | Theme: {theme || 'loading'} +
+ ) + } + + return PerformanceTestComponent +} + // Simulate real page component based on Dify's actual theme usage const PageComponent = () => { const [mounted, setMounted] = useState(false) @@ -227,39 +352,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => { setupMockEnvironment('dark') const timingData: Array<{ phase: string; timestamp: number; styles: any }> = [] - - const TimingPageComponent = () => { - const [mounted, setMounted] = useState(false) - const { theme } = useTheme() - const isDark = mounted ? theme === 'dark' : false - - // Record timing and styles for each render phase - const currentStyles = { - backgroundColor: isDark ? '#1f2937' : '#ffffff', - color: isDark ? '#ffffff' : '#000000', - } - - timingData.push({ - phase: mounted ? 'CSR' : 'Initial', - timestamp: performance.now(), - styles: currentStyles, - }) - - useEffect(() => { - setMounted(true) - }, []) - - return ( -
-
- Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual: {isDark ? 'dark' : 'light'} -
-
- ) - } + const TimingPageComponent = createTimingPageComponent(timingData) render( @@ -295,33 +388,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => { setupMockEnvironment('dark') const cssStates: Array<{ className: string; timestamp: number }> = [] - - const CSSTestComponent = () => { - const [mounted, setMounted] = useState(false) - const { theme } = useTheme() - const isDark = mounted ? theme === 'dark' : false - - // Simulate Tailwind CSS class application - const className = `min-h-screen ${isDark ? 'bg-gray-900 text-white' : 'bg-white text-black'}` - - cssStates.push({ - className, - timestamp: performance.now(), - }) - - useEffect(() => { - setMounted(true) - }, []) - - return ( -
-
Classes: {className}
-
- ) - } + const CSSTestComponent = createCSSTestComponent(cssStates) render( @@ -413,34 +480,12 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => { test('verifies ThemeProvider position fix reduces initialization delay', async () => { const performanceMarks: Array<{ event: string; timestamp: number }> = [] - const PerformanceTestComponent = () => { - const [mounted, setMounted] = useState(false) - const { theme } = useTheme() - - performanceMarks.push({ event: 'component-render', timestamp: performance.now() }) - - useEffect(() => { - performanceMarks.push({ event: 'mount-start', timestamp: performance.now() }) - setMounted(true) - performanceMarks.push({ event: 'mount-complete', timestamp: performance.now() }) - }, []) - - useEffect(() => { - if (theme) - performanceMarks.push({ event: 'theme-available', timestamp: performance.now() }) - }, [theme]) - - return ( -
- Mounted: {mounted.toString()} | Theme: {theme || 'loading'} -
- ) - } - setupMockEnvironment('dark') expect(window.localStorage.getItem('theme')).toBe('dark') + const PerformanceTestComponent = createPerformanceTestComponent(performanceMarks) + render( diff --git a/web/__tests__/unified-tags-logic.test.ts b/web/__tests__/unified-tags-logic.test.ts index c920e28e0a..ec73a6a268 100644 --- a/web/__tests__/unified-tags-logic.test.ts +++ b/web/__tests__/unified-tags-logic.test.ts @@ -70,14 +70,18 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { }) describe('Fallback Logic (from layout-main.tsx)', () => { + type Tag = { id: string; name: string } + type AppDetail = { tags: Tag[] } + type FallbackResult = { tags?: Tag[] } | null + // no-op it('should trigger fallback when tags are missing or empty', () => { - const appDetailWithoutTags = { tags: [] } - const appDetailWithTags = { tags: [{ id: 'tag1' }] } - const appDetailWithUndefinedTags = { tags: undefined as any } + const appDetailWithoutTags: AppDetail = { tags: [] } + const appDetailWithTags: AppDetail = { tags: [{ id: 'tag1', name: 't' }] } + const appDetailWithUndefinedTags: { tags: Tag[] | undefined } = { tags: undefined } // This simulates the condition in layout-main.tsx - const shouldFallback1 = !appDetailWithoutTags.tags || appDetailWithoutTags.tags.length === 0 - const shouldFallback2 = !appDetailWithTags.tags || appDetailWithTags.tags.length === 0 + const shouldFallback1 = appDetailWithoutTags.tags.length === 0 + const shouldFallback2 = appDetailWithTags.tags.length === 0 const shouldFallback3 = !appDetailWithUndefinedTags.tags || appDetailWithUndefinedTags.tags.length === 0 expect(shouldFallback1).toBe(true) // Empty array should trigger fallback @@ -86,24 +90,26 @@ describe('Unified Tags Editing - Pure Logic Tests', () => { }) it('should preserve tags when fallback succeeds', () => { - const originalAppDetail = { tags: [] as any[] } - const fallbackResult = { tags: [{ id: 'tag1', name: 'fallback-tag' }] } + const originalAppDetail: AppDetail = { tags: [] } + const fallbackResult: { tags?: Tag[] } = { tags: [{ id: 'tag1', name: 'fallback-tag' }] } // This simulates the successful fallback in layout-main.tsx - if (fallbackResult?.tags) - originalAppDetail.tags = fallbackResult.tags + const tags = fallbackResult.tags + if (tags) + originalAppDetail.tags = tags expect(originalAppDetail.tags).toEqual(fallbackResult.tags) expect(originalAppDetail.tags.length).toBe(1) }) it('should continue with empty tags when fallback fails', () => { - const originalAppDetail: { tags: any[] } = { tags: [] } - const fallbackResult: { tags?: any[] } | null = null + const originalAppDetail: AppDetail = { tags: [] } + const fallbackResult = null as FallbackResult // This simulates fallback failure in layout-main.tsx - if (fallbackResult?.tags) - originalAppDetail.tags = fallbackResult.tags + const tags: Tag[] | undefined = fallbackResult && 'tags' in fallbackResult ? fallbackResult.tags : undefined + if (tags) + originalAppDetail.tags = tags expect(originalAppDetail.tags).toEqual([]) }) From 762cf91133a735ae7f810ddb0b8f5e23371c1035 Mon Sep 17 00:00:00 2001 From: croatialu Date: Mon, 20 Oct 2025 15:37:30 +0800 Subject: [PATCH 03/14] feat(web): Add parameter rendering to MCP tool item component (#27099) --- .../components/tools/mcp/detail/tool-item.tsx | 28 +++++++++++++++++++ web/i18n/en-US/tools.ts | 4 +++ web/i18n/zh-Hans/tools.ts | 4 +++ 3 files changed, 36 insertions(+) diff --git a/web/app/components/tools/mcp/detail/tool-item.tsx b/web/app/components/tools/mcp/detail/tool-item.tsx index 7a5ea6143d..d5dfa1f978 100644 --- a/web/app/components/tools/mcp/detail/tool-item.tsx +++ b/web/app/components/tools/mcp/detail/tool-item.tsx @@ -6,6 +6,7 @@ import I18n from '@/context/i18n' import { getLanguage } from '@/i18n-config/language' import Tooltip from '@/app/components/base/tooltip' import cn from '@/utils/classnames' +import { useTranslation } from 'react-i18next' type Props = { tool: Tool @@ -16,6 +17,32 @@ const MCPToolItem = ({ }: Props) => { const { locale } = useContext(I18n) const language = getLanguage(locale) + const { t } = useTranslation() + + const renderParameters = () => { + const parameters = tool.parameters + + if (parameters.length === 0) + return null + + return ( +
+
{t('tools.mcp.toolItem.parameters')}:
+
    + {parameters.map((parameter) => { + const descriptionContent = parameter.human_description[language] || t('tools.mcp.toolItem.noDescription') + return ( +
  • + {parameter.name} + ({parameter.type}): + {descriptionContent} +
  • + ) + })} +
+
+ ) + } return (
{tool.label[language]}
{tool.description[language]}
+ {renderParameters()} )} > diff --git a/web/i18n/en-US/tools.ts b/web/i18n/en-US/tools.ts index 35d5202879..3fba10447f 100644 --- a/web/i18n/en-US/tools.ts +++ b/web/i18n/en-US/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: 'App not published. Please publish the app first.', }, + toolItem: { + noDescription: 'No description', + parameters: 'Parameters', + }, }, allTools: 'All tools', } diff --git a/web/i18n/zh-Hans/tools.ts b/web/i18n/zh-Hans/tools.ts index e45d396617..15b1c7f592 100644 --- a/web/i18n/zh-Hans/tools.ts +++ b/web/i18n/zh-Hans/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: '应用未发布。请先发布应用。', }, + toolItem: { + parameters: '参数', + noDescription: '暂无描述', + }, }, allTools: '全部工具', } From f28b519556080c20d878cae08c1a0ac981b739cc Mon Sep 17 00:00:00 2001 From: -LAN- Date: Mon, 20 Oct 2025 15:39:07 +0800 Subject: [PATCH 04/14] Allow custom app headers in CORS configuration (#27133) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/extensions/ext_blueprints.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/api/extensions/ext_blueprints.py b/api/extensions/ext_blueprints.py index 52fef4929f..82f0542b35 100644 --- a/api/extensions/ext_blueprints.py +++ b/api/extensions/ext_blueprints.py @@ -1,7 +1,12 @@ from configs import dify_config -from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN +from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN, HEADER_NAME_PASSPORT from dify_app import DifyApp +BASE_CORS_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, HEADER_NAME_PASSPORT) +SERVICE_API_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, "Authorization") +AUTHENTICATED_HEADERS: tuple[str, ...] = (*SERVICE_API_HEADERS, HEADER_NAME_CSRF_TOKEN) +FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN) + def init_app(app: DifyApp): # register blueprint routers @@ -17,7 +22,7 @@ def init_app(app: DifyApp): CORS( service_api_bp, - allow_headers=["Content-Type", "Authorization", HEADER_NAME_APP_CODE], + allow_headers=list(SERVICE_API_HEADERS), methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"], ) app.register_blueprint(service_api_bp) @@ -26,7 +31,7 @@ def init_app(app: DifyApp): web_bp, resources={r"/*": {"origins": dify_config.WEB_API_CORS_ALLOW_ORIGINS}}, supports_credentials=True, - allow_headers=["Content-Type", "Authorization", HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN], + allow_headers=list(AUTHENTICATED_HEADERS), methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"], expose_headers=["X-Version", "X-Env"], ) @@ -36,7 +41,7 @@ def init_app(app: DifyApp): console_app_bp, resources={r"/*": {"origins": dify_config.CONSOLE_CORS_ALLOW_ORIGINS}}, supports_credentials=True, - allow_headers=["Content-Type", "Authorization", HEADER_NAME_CSRF_TOKEN], + allow_headers=list(AUTHENTICATED_HEADERS), methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"], expose_headers=["X-Version", "X-Env"], ) @@ -44,7 +49,7 @@ def init_app(app: DifyApp): CORS( files_bp, - allow_headers=["Content-Type", HEADER_NAME_CSRF_TOKEN], + allow_headers=list(FILES_HEADERS), methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"], ) app.register_blueprint(files_bp) From 9f22b2726bc038778e65fb29d2887a57e9c2ba22 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 15:45:06 +0800 Subject: [PATCH 05/14] chore: translate i18n files and update type definitions (#27141) Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> --- web/i18n/de-DE/tools.ts | 4 ++++ web/i18n/es-ES/tools.ts | 4 ++++ web/i18n/fa-IR/tools.ts | 4 ++++ web/i18n/fr-FR/tools.ts | 4 ++++ web/i18n/hi-IN/tools.ts | 4 ++++ web/i18n/id-ID/tools.ts | 4 ++++ web/i18n/it-IT/tools.ts | 4 ++++ web/i18n/ja-JP/tools.ts | 4 ++++ web/i18n/ko-KR/tools.ts | 4 ++++ web/i18n/pl-PL/tools.ts | 4 ++++ web/i18n/pt-BR/tools.ts | 4 ++++ web/i18n/ro-RO/tools.ts | 4 ++++ web/i18n/ru-RU/tools.ts | 4 ++++ web/i18n/sl-SI/tools.ts | 4 ++++ web/i18n/th-TH/tools.ts | 4 ++++ web/i18n/tr-TR/tools.ts | 4 ++++ web/i18n/uk-UA/tools.ts | 4 ++++ web/i18n/vi-VN/tools.ts | 4 ++++ web/i18n/zh-Hant/tools.ts | 4 ++++ 19 files changed, 76 insertions(+) diff --git a/web/i18n/de-DE/tools.ts b/web/i18n/de-DE/tools.ts index a838c224e6..8cef76b732 100644 --- a/web/i18n/de-DE/tools.ts +++ b/web/i18n/de-DE/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: 'App nicht veröffentlicht. Bitte zuerst die App veröffentlichen.', }, + toolItem: { + parameters: 'Parameter', + noDescription: 'Keine Beschreibung', + }, }, allTools: 'Alle Werkzeuge', } diff --git a/web/i18n/es-ES/tools.ts b/web/i18n/es-ES/tools.ts index a53752c4e0..10584c41ca 100644 --- a/web/i18n/es-ES/tools.ts +++ b/web/i18n/es-ES/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: 'App no publicada. Publícala primero.', }, + toolItem: { + noDescription: 'Sin descripción', + parameters: 'Parámetros', + }, }, allTools: 'Todas las herramientas', } diff --git a/web/i18n/fa-IR/tools.ts b/web/i18n/fa-IR/tools.ts index 6fd29d29fc..587c16d960 100644 --- a/web/i18n/fa-IR/tools.ts +++ b/web/i18n/fa-IR/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: 'برنامه منتشر نشده است. لطفاً ابتدا برنامه را منتشر کنید.', }, + toolItem: { + parameters: 'پارامترها', + noDescription: 'بدون توضیح', + }, }, allTools: 'همه ابزارها', } diff --git a/web/i18n/fr-FR/tools.ts b/web/i18n/fr-FR/tools.ts index ea49f62579..c91952d6c5 100644 --- a/web/i18n/fr-FR/tools.ts +++ b/web/i18n/fr-FR/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: 'Application non publiée. Merci de publier l\'application en premier.', }, + toolItem: { + parameters: 'Paramètres', + noDescription: 'Aucune description', + }, }, allTools: 'Tous les outils', } diff --git a/web/i18n/hi-IN/tools.ts b/web/i18n/hi-IN/tools.ts index 5b342c569d..7279d3bcbe 100644 --- a/web/i18n/hi-IN/tools.ts +++ b/web/i18n/hi-IN/tools.ts @@ -248,6 +248,10 @@ const translation = { }, publishTip: 'ऐप प्रकाशित नहीं हुआ। कृपया पहले ऐप प्रकाशित करें।', }, + toolItem: { + parameters: 'पैरामीटर', + noDescription: 'कोई वर्णन नहीं', + }, }, allTools: 'सभी उपकरण', } diff --git a/web/i18n/id-ID/tools.ts b/web/i18n/id-ID/tools.ts index 427e77867f..e3817e0111 100644 --- a/web/i18n/id-ID/tools.ts +++ b/web/i18n/id-ID/tools.ts @@ -226,6 +226,10 @@ const translation = { toolUpdateConfirmContent: 'Memperbarui daftar alat dapat memengaruhi aplikasi yang ada. Apakah Anda ingin melanjutkan?', update: 'Pemutakhiran', identifier: 'Pengenal Server (Klik untuk Menyalin)', + toolItem: { + parameters: 'Parameter', + noDescription: 'Tanpa deskripsi', + }, }, title: 'Perkakas', createCustomTool: 'Buat Alat Kustom', diff --git a/web/i18n/it-IT/tools.ts b/web/i18n/it-IT/tools.ts index b12c07a0f8..5e54b8f837 100644 --- a/web/i18n/it-IT/tools.ts +++ b/web/i18n/it-IT/tools.ts @@ -253,6 +253,10 @@ const translation = { }, publishTip: 'App non pubblicata. Pubblica l\'app prima.', }, + toolItem: { + parameters: 'Parametri', + noDescription: 'Nessuna descrizione', + }, }, allTools: 'Tutti gli strumenti', } diff --git a/web/i18n/ja-JP/tools.ts b/web/i18n/ja-JP/tools.ts index 5b5dc3d07e..2fed3768c0 100644 --- a/web/i18n/ja-JP/tools.ts +++ b/web/i18n/ja-JP/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: 'アプリが公開されていません。まずアプリを公開してください。', }, + toolItem: { + parameters: 'パラメータ', + noDescription: '説明なし', + }, }, allTools: 'すべての道具', } diff --git a/web/i18n/ko-KR/tools.ts b/web/i18n/ko-KR/tools.ts index 988d06cdd5..d8e975e61c 100644 --- a/web/i18n/ko-KR/tools.ts +++ b/web/i18n/ko-KR/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: '앱이 게시되지 않았습니다. 먼저 앱을 게시하십시오.', }, + toolItem: { + noDescription: '설명 없음', + parameters: '매개변수', + }, }, allTools: '모든 도구', } diff --git a/web/i18n/pl-PL/tools.ts b/web/i18n/pl-PL/tools.ts index eddf1f8da4..dfa83d1231 100644 --- a/web/i18n/pl-PL/tools.ts +++ b/web/i18n/pl-PL/tools.ts @@ -247,6 +247,10 @@ const translation = { }, publishTip: 'Aplikacja nieopublikowana. Najpierw opublikuj aplikację.', }, + toolItem: { + parameters: 'Parametry', + noDescription: 'Brak opisu', + }, }, allTools: 'Wszystkie narzędzia', } diff --git a/web/i18n/pt-BR/tools.ts b/web/i18n/pt-BR/tools.ts index ae05738137..401a81f615 100644 --- a/web/i18n/pt-BR/tools.ts +++ b/web/i18n/pt-BR/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: 'Aplicativo não publicado. Por favor, publique o aplicativo primeiro.', }, + toolItem: { + noDescription: 'Sem descrição', + parameters: 'Parâmetros', + }, }, allTools: 'Todas as ferramentas', } diff --git a/web/i18n/ro-RO/tools.ts b/web/i18n/ro-RO/tools.ts index 6c534a6be5..b732128684 100644 --- a/web/i18n/ro-RO/tools.ts +++ b/web/i18n/ro-RO/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: 'Aplicație nepublicată. Publicați aplicația mai întâi.', }, + toolItem: { + parameters: 'Parametri', + noDescription: 'Fără descriere', + }, }, allTools: 'Toate instrumentele', } diff --git a/web/i18n/ru-RU/tools.ts b/web/i18n/ru-RU/tools.ts index 97efd5f551..36d48affc2 100644 --- a/web/i18n/ru-RU/tools.ts +++ b/web/i18n/ru-RU/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: 'Приложение не опубликовано. Пожалуйста, сначала опубликуйте приложение.', }, + toolItem: { + parameters: 'Параметры', + noDescription: 'Нет описания', + }, }, allTools: 'Все инструменты', } diff --git a/web/i18n/sl-SI/tools.ts b/web/i18n/sl-SI/tools.ts index 08c14a9acd..8eb28c21bf 100644 --- a/web/i18n/sl-SI/tools.ts +++ b/web/i18n/sl-SI/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: 'Aplikacija ni objavljena. Najprej objavite aplikacijo.', }, + toolItem: { + parameters: 'Parametri', + noDescription: 'Brez opisa', + }, }, allTools: 'Vsa orodja', } diff --git a/web/i18n/th-TH/tools.ts b/web/i18n/th-TH/tools.ts index 61ca965ead..71175ff26c 100644 --- a/web/i18n/th-TH/tools.ts +++ b/web/i18n/th-TH/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: 'แอปไม่ถูกเผยแพร่ กรุณาเผยแพร่แอปก่อน', }, + toolItem: { + noDescription: 'ไม่มีคำอธิบาย', + parameters: 'พารามิเตอร์', + }, }, allTools: 'เครื่องมือทั้งหมด', } diff --git a/web/i18n/tr-TR/tools.ts b/web/i18n/tr-TR/tools.ts index 84d22185a7..d309b78689 100644 --- a/web/i18n/tr-TR/tools.ts +++ b/web/i18n/tr-TR/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: 'Uygulama yayınlanmadı. Lütfen önce uygulamayı yayınlayın.', }, + toolItem: { + parameters: 'Parametreler', + noDescription: 'Açıklama yok', + }, }, allTools: 'Tüm araçlar', } diff --git a/web/i18n/uk-UA/tools.ts b/web/i18n/uk-UA/tools.ts index e20f82e066..596153974f 100644 --- a/web/i18n/uk-UA/tools.ts +++ b/web/i18n/uk-UA/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: 'Додаток не опубліковано. Спочатку опублікуйте додаток.', }, + toolItem: { + parameters: 'Параметри', + noDescription: 'Немає опису', + }, }, allTools: 'Всі інструменти', } diff --git a/web/i18n/vi-VN/tools.ts b/web/i18n/vi-VN/tools.ts index 5ed60527a7..7c0826890e 100644 --- a/web/i18n/vi-VN/tools.ts +++ b/web/i18n/vi-VN/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: 'Ứng dụng chưa xuất bản. Vui lòng xuất bản ứng dụng trước.', }, + toolItem: { + parameters: 'Các thông số', + noDescription: 'Không có mô tả', + }, }, allTools: 'Tất cả các công cụ', } diff --git a/web/i18n/zh-Hant/tools.ts b/web/i18n/zh-Hant/tools.ts index e904f1bda9..3c53b87c72 100644 --- a/web/i18n/zh-Hant/tools.ts +++ b/web/i18n/zh-Hant/tools.ts @@ -243,6 +243,10 @@ const translation = { }, publishTip: '應用程式尚未發布。請先發布應用程式。', }, + toolItem: { + parameters: '參數', + noDescription: '無描述', + }, }, allTools: '所有工具', } From d7d9abb007f136fd10e823fee20cf9e15b972b0d Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 20 Oct 2025 16:54:31 +0800 Subject: [PATCH 06/14] chore: use new api to check login status (#27143) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- web/service/use-common.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/web/service/use-common.ts b/web/service/use-common.ts index 3e01b721e8..57b9c8b165 100644 --- a/web/service/use-common.ts +++ b/web/service/use-common.ts @@ -116,7 +116,19 @@ export const useIsLogin = () => { queryKey: [NAME_SPACE, 'is-login'], staleTime: 0, gcTime: 0, - queryFn: () => get('/login/status'), + queryFn: async (): Promise => { + try { + await get('/account/profile', { + silent: true, + }) + } + catch (e: any) { + if(e.status === 401) + return { logged_in: false } + return { logged_in: true } + } + return { logged_in: true } + }, }) } From fd845c8b6c38ea7fcff9f34507bd7067ab57fd95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Mon, 20 Oct 2025 18:30:52 +0800 Subject: [PATCH 07/14] chore: add more stories (#27142) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- web/.storybook/__mocks__/context-block.tsx | 4 + web/.storybook/__mocks__/history-block.tsx | 4 + web/.storybook/__mocks__/query-block.tsx | 4 + web/.storybook/main.ts | 17 + .../base/action-button/index.stories.tsx | 262 ++++++++ .../auto-height-textarea/index.stories.tsx | 204 ++++++ .../base/block-input/index.stories.tsx | 191 ++++++ .../base/chat/chat/answer/index.stories.tsx | 7 +- .../base/checkbox/index.stories.tsx | 394 +++++++++++ .../base/input-number/index.stories.tsx | 438 ++++++++++++ .../components/base/input/index.stories.tsx | 424 ++++++++++++ .../base/prompt-editor/index.stories.tsx | 360 ++++++++++ .../base/radio-card/index.stories.tsx | 504 ++++++++++++++ .../components/base/radio/index.stories.tsx | 421 ++++++++++++ .../base/search-input/index.stories.tsx | 435 ++++++++++++ .../components/base/select/index.stories.tsx | 527 +++++++++++++++ .../components/base/slider/index.stories.tsx | 560 ++++++++++++++++ .../components/base/switch/index.stories.tsx | 626 ++++++++++++++++++ .../base/tag-input/index.stories.tsx | 516 +++++++++++++++ .../base/textarea/index.stories.tsx | 535 +++++++++++++++ .../base/voice-input/index.stories.tsx | 499 ++++++++++++++ .../with-input-validation/index.stories.tsx | 491 ++++++++++++++ 22 files changed, 7421 insertions(+), 2 deletions(-) create mode 100644 web/.storybook/__mocks__/context-block.tsx create mode 100644 web/.storybook/__mocks__/history-block.tsx create mode 100644 web/.storybook/__mocks__/query-block.tsx create mode 100644 web/app/components/base/action-button/index.stories.tsx create mode 100644 web/app/components/base/auto-height-textarea/index.stories.tsx create mode 100644 web/app/components/base/block-input/index.stories.tsx create mode 100644 web/app/components/base/checkbox/index.stories.tsx create mode 100644 web/app/components/base/input-number/index.stories.tsx create mode 100644 web/app/components/base/input/index.stories.tsx create mode 100644 web/app/components/base/prompt-editor/index.stories.tsx create mode 100644 web/app/components/base/radio-card/index.stories.tsx create mode 100644 web/app/components/base/radio/index.stories.tsx create mode 100644 web/app/components/base/search-input/index.stories.tsx create mode 100644 web/app/components/base/select/index.stories.tsx create mode 100644 web/app/components/base/slider/index.stories.tsx create mode 100644 web/app/components/base/switch/index.stories.tsx create mode 100644 web/app/components/base/tag-input/index.stories.tsx create mode 100644 web/app/components/base/textarea/index.stories.tsx create mode 100644 web/app/components/base/voice-input/index.stories.tsx create mode 100644 web/app/components/base/with-input-validation/index.stories.tsx diff --git a/web/.storybook/__mocks__/context-block.tsx b/web/.storybook/__mocks__/context-block.tsx new file mode 100644 index 0000000000..8a9d8625cc --- /dev/null +++ b/web/.storybook/__mocks__/context-block.tsx @@ -0,0 +1,4 @@ +// Mock for context-block plugin to avoid circular dependency in Storybook +export const ContextBlockNode = null +export const ContextBlockReplacementBlock = null +export default null diff --git a/web/.storybook/__mocks__/history-block.tsx b/web/.storybook/__mocks__/history-block.tsx new file mode 100644 index 0000000000..e3c3965d13 --- /dev/null +++ b/web/.storybook/__mocks__/history-block.tsx @@ -0,0 +1,4 @@ +// Mock for history-block plugin to avoid circular dependency in Storybook +export const HistoryBlockNode = null +export const HistoryBlockReplacementBlock = null +export default null diff --git a/web/.storybook/__mocks__/query-block.tsx b/web/.storybook/__mocks__/query-block.tsx new file mode 100644 index 0000000000..d82f51363a --- /dev/null +++ b/web/.storybook/__mocks__/query-block.tsx @@ -0,0 +1,4 @@ +// Mock for query-block plugin to avoid circular dependency in Storybook +export const QueryBlockNode = null +export const QueryBlockReplacementBlock = null +export default null diff --git a/web/.storybook/main.ts b/web/.storybook/main.ts index 0605c71346..e656115ceb 100644 --- a/web/.storybook/main.ts +++ b/web/.storybook/main.ts @@ -1,4 +1,9 @@ import type { StorybookConfig } from '@storybook/nextjs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) const config: StorybookConfig = { stories: ['../app/components/**/*.stories.@(js|jsx|mjs|ts|tsx)'], @@ -25,5 +30,17 @@ const config: StorybookConfig = { docs: { defaultName: 'Documentation', }, + webpackFinal: async (config) => { + // Add alias to mock problematic modules with circular dependencies + config.resolve = config.resolve || {} + config.resolve.alias = { + ...config.resolve.alias, + // Mock the plugin index files to avoid circular dependencies + [path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/context-block/index.tsx')]: path.resolve(__dirname, '__mocks__/context-block.tsx'), + [path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/history-block/index.tsx')]: path.resolve(__dirname, '__mocks__/history-block.tsx'), + [path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/query-block/index.tsx')]: path.resolve(__dirname, '__mocks__/query-block.tsx'), + } + return config + }, } export default config diff --git a/web/app/components/base/action-button/index.stories.tsx b/web/app/components/base/action-button/index.stories.tsx new file mode 100644 index 0000000000..c174adbc73 --- /dev/null +++ b/web/app/components/base/action-button/index.stories.tsx @@ -0,0 +1,262 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { RiAddLine, RiDeleteBinLine, RiEditLine, RiMore2Fill, RiSaveLine, RiShareLine } from '@remixicon/react' +import ActionButton, { ActionButtonState } from '.' + +const meta = { + title: 'Base/ActionButton', + component: ActionButton, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Action button component with multiple sizes and states. Commonly used for toolbar actions and inline operations.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + size: { + control: 'select', + options: ['xs', 'm', 'l', 'xl'], + description: 'Button size', + }, + state: { + control: 'select', + options: [ + ActionButtonState.Default, + ActionButtonState.Active, + ActionButtonState.Disabled, + ActionButtonState.Destructive, + ActionButtonState.Hover, + ], + description: 'Button state', + }, + children: { + control: 'text', + description: 'Button content', + }, + disabled: { + control: 'boolean', + description: 'Native disabled state', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// Default state +export const Default: Story = { + args: { + size: 'm', + children: , + }, +} + +// With text +export const WithText: Story = { + args: { + size: 'm', + children: 'Edit', + }, +} + +// Icon with text +export const IconWithText: Story = { + args: { + size: 'm', + children: ( + <> + + Add Item + + ), + }, +} + +// Size variations +export const ExtraSmall: Story = { + args: { + size: 'xs', + children: , + }, +} + +export const Small: Story = { + args: { + size: 'xs', + children: , + }, +} + +export const Medium: Story = { + args: { + size: 'm', + children: , + }, +} + +export const Large: Story = { + args: { + size: 'l', + children: , + }, +} + +export const ExtraLarge: Story = { + args: { + size: 'xl', + children: , + }, +} + +// State variations +export const ActiveState: Story = { + args: { + size: 'm', + state: ActionButtonState.Active, + children: , + }, +} + +export const DisabledState: Story = { + args: { + size: 'm', + state: ActionButtonState.Disabled, + children: , + }, +} + +export const DestructiveState: Story = { + args: { + size: 'm', + state: ActionButtonState.Destructive, + children: , + }, +} + +export const HoverState: Story = { + args: { + size: 'm', + state: ActionButtonState.Hover, + children: , + }, +} + +// Real-world examples +export const ToolbarActions: Story = { + render: () => ( +
+ + + + + + + + + +
+ + + +
+ ), +} + +export const InlineActions: Story = { + render: () => ( +
+ Item name + + + + + + +
+ ), +} + +export const SizeComparison: Story = { + render: () => ( +
+
+ + + + XS +
+
+ + + + S +
+
+ + + + M +
+
+ + + + L +
+
+ + + + XL +
+
+ ), +} + +export const StateComparison: Story = { + render: () => ( +
+
+ + + + Default +
+
+ + + + Active +
+
+ + + + Hover +
+
+ + + + Disabled +
+
+ + + + Destructive +
+
+ ), +} + +// Interactive playground +export const Playground: Story = { + args: { + size: 'm', + state: ActionButtonState.Default, + children: , + }, +} diff --git a/web/app/components/base/auto-height-textarea/index.stories.tsx b/web/app/components/base/auto-height-textarea/index.stories.tsx new file mode 100644 index 0000000000..f083e4f56d --- /dev/null +++ b/web/app/components/base/auto-height-textarea/index.stories.tsx @@ -0,0 +1,204 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import AutoHeightTextarea from '.' + +const meta = { + title: 'Base/AutoHeightTextarea', + component: AutoHeightTextarea, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Auto-resizing textarea component that expands and contracts based on content, with configurable min/max height constraints.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + placeholder: { + control: 'text', + description: 'Placeholder text', + }, + value: { + control: 'text', + description: 'Textarea value', + }, + minHeight: { + control: 'number', + description: 'Minimum height in pixels', + }, + maxHeight: { + control: 'number', + description: 'Maximum height in pixels', + }, + autoFocus: { + control: 'boolean', + description: 'Auto focus on mount', + }, + className: { + control: 'text', + description: 'Additional CSS classes', + }, + wrapperClassName: { + control: 'text', + description: 'Wrapper CSS classes', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// Interactive demo wrapper +const AutoHeightTextareaDemo = (args: any) => { + const [value, setValue] = useState(args.value || '') + + return ( +
+ { + setValue(e.target.value) + console.log('Text changed:', e.target.value) + }} + /> +
+ ) +} + +// Default state +export const Default: Story = { + render: args => , + args: { + placeholder: 'Type something...', + value: '', + minHeight: 36, + maxHeight: 96, + className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500', + }, +} + +// With initial value +export const WithInitialValue: Story = { + render: args => , + args: { + placeholder: 'Type something...', + value: 'This is a pre-filled textarea with some initial content.', + minHeight: 36, + maxHeight: 96, + className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500', + }, +} + +// With multiline content +export const MultilineContent: Story = { + render: args => , + args: { + placeholder: 'Type something...', + value: 'Line 1\nLine 2\nLine 3\nLine 4\nThis textarea automatically expands to fit the content.', + minHeight: 36, + maxHeight: 96, + className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500', + }, +} + +// Custom min height +export const CustomMinHeight: Story = { + render: args => , + args: { + placeholder: 'Taller minimum height...', + value: '', + minHeight: 100, + maxHeight: 200, + className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500', + }, +} + +// Small max height (scrollable) +export const SmallMaxHeight: Story = { + render: args => , + args: { + placeholder: 'Type multiple lines...', + value: 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nThis will become scrollable when it exceeds max height.', + minHeight: 36, + maxHeight: 80, + className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500', + }, +} + +// Auto focus enabled +export const AutoFocus: Story = { + render: args => , + args: { + placeholder: 'This textarea auto-focuses on mount', + value: '', + minHeight: 36, + maxHeight: 96, + autoFocus: true, + className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500', + }, +} + +// With custom styling +export const CustomStyling: Story = { + render: args => , + args: { + placeholder: 'Custom styled textarea...', + value: '', + minHeight: 50, + maxHeight: 150, + className: 'w-full p-3 bg-gray-50 border-2 border-blue-400 rounded-xl text-lg focus:outline-none focus:bg-white focus:border-blue-600', + wrapperClassName: 'shadow-lg', + }, +} + +// Long content example +export const LongContent: Story = { + render: args => , + args: { + placeholder: 'Type something...', + value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', + minHeight: 36, + maxHeight: 200, + className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500', + }, +} + +// Real-world example - Chat input +export const ChatInput: Story = { + render: args => , + args: { + placeholder: 'Type your message...', + value: '', + minHeight: 40, + maxHeight: 120, + className: 'w-full px-4 py-2 bg-gray-100 border border-gray-300 rounded-2xl text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-blue-500', + }, +} + +// Real-world example - Comment box +export const CommentBox: Story = { + render: args => , + args: { + placeholder: 'Write a comment...', + value: '', + minHeight: 60, + maxHeight: 200, + className: 'w-full p-3 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500', + }, +} + +// Interactive playground +export const Playground: Story = { + render: args => , + args: { + placeholder: 'Type something...', + value: '', + minHeight: 36, + maxHeight: 96, + autoFocus: false, + className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500', + wrapperClassName: '', + }, +} diff --git a/web/app/components/base/block-input/index.stories.tsx b/web/app/components/base/block-input/index.stories.tsx new file mode 100644 index 0000000000..0685f4150f --- /dev/null +++ b/web/app/components/base/block-input/index.stories.tsx @@ -0,0 +1,191 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import BlockInput from '.' + +const meta = { + title: 'Base/BlockInput', + component: BlockInput, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Block input component with variable highlighting. Supports {{variable}} syntax with validation and visual highlighting of variable names.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + value: { + control: 'text', + description: 'Input value (supports {{variable}} syntax)', + }, + className: { + control: 'text', + description: 'Wrapper CSS classes', + }, + highLightClassName: { + control: 'text', + description: 'CSS class for highlighted variables (default: text-blue-500)', + }, + readonly: { + control: 'boolean', + description: 'Read-only mode', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// Interactive demo wrapper +const BlockInputDemo = (args: any) => { + const [value, setValue] = useState(args.value || '') + const [keys, setKeys] = useState([]) + + return ( +
+ { + setValue(newValue) + setKeys(extractedKeys) + console.log('Value confirmed:', newValue) + console.log('Extracted keys:', extractedKeys) + }} + /> + {keys.length > 0 && ( +
+
Detected Variables:
+
+ {keys.map(key => ( + + {key} + + ))} +
+
+ )} +
+ ) +} + +// Default state +export const Default: Story = { + render: args => , + args: { + value: '', + readonly: false, + }, +} + +// With single variable +export const SingleVariable: Story = { + render: args => , + args: { + value: 'Hello {{name}}, welcome to the application!', + readonly: false, + }, +} + +// With multiple variables +export const MultipleVariables: Story = { + render: args => , + args: { + value: 'Dear {{user_name}},\n\nYour order {{order_id}} has been shipped to {{address}}.\n\nThank you for shopping with us!', + readonly: false, + }, +} + +// Complex template +export const ComplexTemplate: Story = { + render: args => , + args: { + value: 'Hi {{customer_name}},\n\nYour {{product_type}} subscription will renew on {{renewal_date}} for {{amount}}.\n\nYour payment method ending in {{card_last_4}} will be charged.\n\nQuestions? Contact us at {{support_email}}.', + readonly: false, + }, +} + +// Read-only mode +export const ReadOnlyMode: Story = { + render: args => , + args: { + value: 'This is a read-only template with {{variable1}} and {{variable2}}.\n\nYou cannot edit this content.', + readonly: true, + }, +} + +// Empty state +export const EmptyState: Story = { + render: args => , + args: { + value: '', + readonly: false, + }, +} + +// Long content +export const LongContent: Story = { + render: args => , + args: { + value: 'Dear {{recipient_name}},\n\nWe are writing to inform you about the upcoming changes to your {{service_name}} account.\n\nEffective {{effective_date}}, your plan will include:\n\n1. Access to {{feature_1}}\n2. {{feature_2}} with unlimited usage\n3. Priority support via {{support_channel}}\n4. Monthly reports sent to {{email_address}}\n\nYour new monthly rate will be {{new_price}}, compared to your current rate of {{old_price}}.\n\nIf you have any questions, please contact our team at {{contact_info}}.\n\nBest regards,\n{{company_name}} Team', + readonly: false, + }, +} + +// Variables with underscores +export const VariablesWithUnderscores: Story = { + render: args => , + args: { + value: 'User {{user_id}} from {{user_country}} has {{total_orders}} orders with status {{order_status}}.', + readonly: false, + }, +} + +// Adjacent variables +export const AdjacentVariables: Story = { + render: args => , + args: { + value: 'File: {{file_name}}.{{file_extension}} ({{file_size}}{{size_unit}})', + readonly: false, + }, +} + +// Real-world example - Email template +export const EmailTemplate: Story = { + render: args => , + args: { + value: 'Subject: Your {{service_name}} account has been created\n\nHi {{first_name}},\n\nWelcome to {{company_name}}! Your account is now active.\n\nUsername: {{username}}\nEmail: {{email}}\n\nGet started at {{app_url}}\n\nThanks,\nThe {{company_name}} Team', + readonly: false, + }, +} + +// Real-world example - Notification template +export const NotificationTemplate: Story = { + render: args => , + args: { + value: '🔔 {{user_name}} mentioned you in {{channel_name}}\n\n"{{message_preview}}"\n\nReply now: {{message_url}}', + readonly: false, + }, +} + +// Custom styling +export const CustomStyling: Story = { + render: args => , + args: { + value: 'This template uses {{custom_variable}} with custom styling.', + readonly: false, + className: 'bg-gray-50 border-2 border-blue-200', + }, +} + +// Interactive playground +export const Playground: Story = { + render: args => , + args: { + value: 'Try editing this text and adding variables like {{example}}', + readonly: false, + className: '', + highLightClassName: '', + }, +} diff --git a/web/app/components/base/chat/chat/answer/index.stories.tsx b/web/app/components/base/chat/chat/answer/index.stories.tsx index 1f45844ec4..a83c0fea61 100644 --- a/web/app/components/base/chat/chat/answer/index.stories.tsx +++ b/web/app/components/base/chat/chat/answer/index.stories.tsx @@ -1,7 +1,5 @@ import type { Meta, StoryObj } from '@storybook/nextjs' - import type { ChatItem } from '../../types' -import { mockedWorkflowProcess } from './__mocks__/workflowProcess' import { markdownContent } from './__mocks__/markdownContent' import { markdownContentSVG } from './__mocks__/markdownContentSVG' import Answer from '.' @@ -34,6 +32,11 @@ const mockedBaseChatItem = { content: 'Hello, how can I assist you today?', } satisfies ChatItem +const mockedWorkflowProcess = { + status: 'succeeded', + tracing: [], +} + export const Basic: Story = { args: { item: mockedBaseChatItem, diff --git a/web/app/components/base/checkbox/index.stories.tsx b/web/app/components/base/checkbox/index.stories.tsx new file mode 100644 index 0000000000..65fa8e1b97 --- /dev/null +++ b/web/app/components/base/checkbox/index.stories.tsx @@ -0,0 +1,394 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import Checkbox from '.' + +// Helper function for toggling items in an array +const createToggleItem = ( + items: T[], + setItems: (items: T[]) => void, +) => (id: string) => { + setItems(items.map(item => + item.id === id ? { ...item, checked: !item.checked } as T : item, + )) +} + +const meta = { + title: 'Base/Checkbox', + component: Checkbox, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Checkbox component with support for checked, unchecked, indeterminate, and disabled states.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + checked: { + control: 'boolean', + description: 'Checked state', + }, + indeterminate: { + control: 'boolean', + description: 'Indeterminate state (partially checked)', + }, + disabled: { + control: 'boolean', + description: 'Disabled state', + }, + className: { + control: 'text', + description: 'Additional CSS classes', + }, + id: { + control: 'text', + description: 'HTML id attribute', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// Interactive demo wrapper +const CheckboxDemo = (args: any) => { + const [checked, setChecked] = useState(args.checked || false) + + return ( +
+ { + if (!args.disabled) { + setChecked(!checked) + console.log('Checkbox toggled:', !checked) + } + }} + /> + + {checked ? 'Checked' : 'Unchecked'} + +
+ ) +} + +// Default unchecked +export const Default: Story = { + render: args => , + args: { + checked: false, + disabled: false, + indeterminate: false, + }, +} + +// Checked state +export const Checked: Story = { + render: args => , + args: { + checked: true, + disabled: false, + indeterminate: false, + }, +} + +// Indeterminate state +export const Indeterminate: Story = { + render: args => , + args: { + checked: false, + disabled: false, + indeterminate: true, + }, +} + +// Disabled unchecked +export const DisabledUnchecked: Story = { + render: args => , + args: { + checked: false, + disabled: true, + indeterminate: false, + }, +} + +// Disabled checked +export const DisabledChecked: Story = { + render: args => , + args: { + checked: true, + disabled: true, + indeterminate: false, + }, +} + +// Disabled indeterminate +export const DisabledIndeterminate: Story = { + render: args => , + args: { + checked: false, + disabled: true, + indeterminate: true, + }, +} + +// State comparison +export const StateComparison: Story = { + render: () => ( +
+
+
+ undefined} /> + Unchecked +
+
+ undefined} /> + Checked +
+
+ undefined} /> + Indeterminate +
+
+
+
+ undefined} /> + Disabled +
+
+ undefined} /> + Disabled Checked +
+
+ undefined} /> + Disabled Indeterminate +
+
+
+ ), +} + +// With labels +const WithLabelsDemo = () => { + const [items, setItems] = useState([ + { id: '1', label: 'Enable notifications', checked: true }, + { id: '2', label: 'Enable email updates', checked: false }, + { id: '3', label: 'Enable SMS alerts', checked: false }, + ]) + + const toggleItem = createToggleItem(items, setItems) + + return ( +
+ {items.map(item => ( +
+ toggleItem(item.id)} + /> + +
+ ))} +
+ ) +} + +export const WithLabels: Story = { + render: () => , +} + +// Select all example +const SelectAllExampleDemo = () => { + const [items, setItems] = useState([ + { id: '1', label: 'Item 1', checked: false }, + { id: '2', label: 'Item 2', checked: false }, + { id: '3', label: 'Item 3', checked: false }, + ]) + + const allChecked = items.every(item => item.checked) + const someChecked = items.some(item => item.checked) + const indeterminate = someChecked && !allChecked + + const toggleAll = () => { + const newChecked = !allChecked + setItems(items.map(item => ({ ...item, checked: newChecked }))) + } + + const toggleItem = createToggleItem(items, setItems) + + return ( +
+
+ + Select All +
+
+ {items.map(item => ( +
+ toggleItem(item.id)} + /> + +
+ ))} +
+
+ ) +} + +export const SelectAllExample: Story = { + render: () => , +} + +// Form example +const FormExampleDemo = () => { + const [formData, setFormData] = useState({ + terms: false, + newsletter: false, + privacy: false, + }) + + return ( +
+

Account Settings

+
+
+ setFormData({ ...formData, terms: !formData.terms })} + /> +
+ +

+ Required to continue +

+
+
+
+ setFormData({ ...formData, newsletter: !formData.newsletter })} + /> +
+ +

+ Get updates about new features +

+
+
+
+ setFormData({ ...formData, privacy: !formData.privacy })} + /> +
+ +

+ Required to continue +

+
+
+
+
+ ) +} + +export const FormExample: Story = { + render: () => , +} + +// Task list example +const TaskListExampleDemo = () => { + const [tasks, setTasks] = useState([ + { id: '1', title: 'Review pull request', completed: true }, + { id: '2', title: 'Update documentation', completed: true }, + { id: '3', title: 'Fix navigation bug', completed: false }, + { id: '4', title: 'Deploy to staging', completed: false }, + ]) + + const toggleTask = (id: string) => { + setTasks(tasks.map(task => + task.id === id ? { ...task, completed: !task.completed } : task, + )) + } + + const completedCount = tasks.filter(t => t.completed).length + + return ( +
+
+

Today's Tasks

+ + {completedCount} of {tasks.length} completed + +
+
+ {tasks.map(task => ( +
+ toggleTask(task.id)} + /> + toggleTask(task.id)} + > + {task.title} + +
+ ))} +
+
+ ) +} + +export const TaskListExample: Story = { + render: () => , +} + +// Interactive playground +export const Playground: Story = { + render: args => , + args: { + checked: false, + indeterminate: false, + disabled: false, + id: 'playground-checkbox', + }, +} diff --git a/web/app/components/base/input-number/index.stories.tsx b/web/app/components/base/input-number/index.stories.tsx new file mode 100644 index 0000000000..0fca2e52f9 --- /dev/null +++ b/web/app/components/base/input-number/index.stories.tsx @@ -0,0 +1,438 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import { InputNumber } from '.' + +const meta = { + title: 'Base/InputNumber', + component: InputNumber, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Number input component with increment/decrement buttons. Supports min/max constraints, custom step amounts, and units display.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + value: { + control: 'number', + description: 'Current value', + }, + size: { + control: 'select', + options: ['regular', 'large'], + description: 'Input size', + }, + min: { + control: 'number', + description: 'Minimum value', + }, + max: { + control: 'number', + description: 'Maximum value', + }, + amount: { + control: 'number', + description: 'Step amount for increment/decrement', + }, + unit: { + control: 'text', + description: 'Unit text displayed (e.g., "px", "ms")', + }, + disabled: { + control: 'boolean', + description: 'Disabled state', + }, + defaultValue: { + control: 'number', + description: 'Default value when undefined', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// Interactive demo wrapper +const InputNumberDemo = (args: any) => { + const [value, setValue] = useState(args.value ?? 0) + + return ( +
+ { + setValue(newValue) + console.log('Value changed:', newValue) + }} + /> +
+ Current value: {value} +
+
+ ) +} + +// Default state +export const Default: Story = { + render: args => , + args: { + value: 0, + size: 'regular', + }, +} + +// Large size +export const LargeSize: Story = { + render: args => , + args: { + value: 10, + size: 'large', + }, +} + +// With min/max constraints +export const WithMinMax: Story = { + render: args => , + args: { + value: 5, + min: 0, + max: 10, + size: 'regular', + }, +} + +// With custom step amount +export const CustomStepAmount: Story = { + render: args => , + args: { + value: 50, + amount: 5, + min: 0, + max: 100, + size: 'regular', + }, +} + +// With unit +export const WithUnit: Story = { + render: args => , + args: { + value: 100, + unit: 'px', + min: 0, + max: 1000, + amount: 10, + size: 'regular', + }, +} + +// Disabled state +export const Disabled: Story = { + render: args => , + args: { + value: 42, + disabled: true, + size: 'regular', + }, +} + +// Decimal values +export const DecimalValues: Story = { + render: args => , + args: { + value: 2.5, + amount: 0.5, + min: 0, + max: 10, + size: 'regular', + }, +} + +// Negative values allowed +export const NegativeValues: Story = { + render: args => , + args: { + value: 0, + min: -100, + max: 100, + amount: 10, + size: 'regular', + }, +} + +// Size comparison +const SizeComparisonDemo = () => { + const [regularValue, setRegularValue] = useState(10) + const [largeValue, setLargeValue] = useState(20) + + return ( +
+
+ + +
+
+ + +
+
+ ) +} + +export const SizeComparison: Story = { + render: () => , +} + +// Real-world example - Font size picker +const FontSizePickerDemo = () => { + const [fontSize, setFontSize] = useState(16) + + return ( +
+
+
+ + +
+
+

+ Preview Text +

+
+
+
+ ) +} + +export const FontSizePicker: Story = { + render: () => , +} + +// Real-world example - Quantity selector +const QuantitySelectorDemo = () => { + const [quantity, setQuantity] = useState(1) + const pricePerItem = 29.99 + const total = (quantity * pricePerItem).toFixed(2) + + return ( +
+
+
+
+

Product Name

+

${pricePerItem} each

+
+
+
+ + +
+
+
+ Total + ${total} +
+
+
+
+ ) +} + +export const QuantitySelector: Story = { + render: () => , +} + +// Real-world example - Timer settings +const TimerSettingsDemo = () => { + const [hours, setHours] = useState(0) + const [minutes, setMinutes] = useState(15) + const [seconds, setSeconds] = useState(30) + + const totalSeconds = hours * 3600 + minutes * 60 + seconds + + return ( +
+

Timer Configuration

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ Total duration: {totalSeconds} seconds +
+
+
+
+ ) +} + +export const TimerSettings: Story = { + render: () => , +} + +// Real-world example - Animation settings +const AnimationSettingsDemo = () => { + const [duration, setDuration] = useState(300) + const [delay, setDelay] = useState(0) + const [iterations, setIterations] = useState(1) + + return ( +
+

Animation Properties

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ animation: {duration}ms {delay}ms {iterations} +
+
+
+
+ ) +} + +export const AnimationSettings: Story = { + render: () => , +} + +// Real-world example - Temperature control +const TemperatureControlDemo = () => { + const [temperature, setTemperature] = useState(20) + const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1) + + return ( +
+

Temperature Control

+
+
+ + +
+
+
+
Celsius
+
{temperature}°C
+
+
+
Fahrenheit
+
{fahrenheit}°F
+
+
+
+
+ ) +} + +export const TemperatureControl: Story = { + render: () => , +} + +// Interactive playground +export const Playground: Story = { + render: args => , + args: { + value: 10, + size: 'regular', + min: 0, + max: 100, + amount: 1, + unit: '', + disabled: false, + defaultValue: 0, + }, +} diff --git a/web/app/components/base/input/index.stories.tsx b/web/app/components/base/input/index.stories.tsx new file mode 100644 index 0000000000..cd857bc180 --- /dev/null +++ b/web/app/components/base/input/index.stories.tsx @@ -0,0 +1,424 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import Input from '.' + +const meta = { + title: 'Base/Input', + component: Input, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Input component with support for icons, clear button, validation states, and units. Includes automatic leading zero removal for number inputs.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + size: { + control: 'select', + options: ['regular', 'large'], + description: 'Input size', + }, + type: { + control: 'select', + options: ['text', 'number', 'email', 'password', 'url', 'tel'], + description: 'Input type', + }, + placeholder: { + control: 'text', + description: 'Placeholder text', + }, + disabled: { + control: 'boolean', + description: 'Disabled state', + }, + destructive: { + control: 'boolean', + description: 'Error/destructive state', + }, + showLeftIcon: { + control: 'boolean', + description: 'Show search icon on left', + }, + showClearIcon: { + control: 'boolean', + description: 'Show clear button when input has value', + }, + unit: { + control: 'text', + description: 'Unit text displayed on right (e.g., "px", "ms")', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// Interactive demo wrapper +const InputDemo = (args: any) => { + const [value, setValue] = useState(args.value || '') + + return ( +
+ { + setValue(e.target.value) + console.log('Input changed:', e.target.value) + }} + onClear={() => { + setValue('') + console.log('Input cleared') + }} + /> +
+ ) +} + +// Default state +export const Default: Story = { + render: args => , + args: { + size: 'regular', + placeholder: 'Enter text...', + type: 'text', + }, +} + +// Large size +export const LargeSize: Story = { + render: args => , + args: { + size: 'large', + placeholder: 'Enter text...', + type: 'text', + }, +} + +// With search icon +export const WithSearchIcon: Story = { + render: args => , + args: { + size: 'regular', + showLeftIcon: true, + placeholder: 'Search...', + type: 'text', + }, +} + +// With clear button +export const WithClearButton: Story = { + render: args => , + args: { + size: 'regular', + showClearIcon: true, + value: 'Some text to clear', + placeholder: 'Type something...', + type: 'text', + }, +} + +// Search input (icon + clear) +export const SearchInput: Story = { + render: args => , + args: { + size: 'regular', + showLeftIcon: true, + showClearIcon: true, + value: '', + placeholder: 'Search...', + type: 'text', + }, +} + +// Disabled state +export const Disabled: Story = { + render: args => , + args: { + size: 'regular', + value: 'Disabled input', + disabled: true, + type: 'text', + }, +} + +// Destructive/error state +export const DestructiveState: Story = { + render: args => , + args: { + size: 'regular', + value: 'invalid@email', + destructive: true, + placeholder: 'Enter email...', + type: 'email', + }, +} + +// Number input +export const NumberInput: Story = { + render: args => , + args: { + size: 'regular', + type: 'number', + placeholder: 'Enter a number...', + value: '0', + }, +} + +// With unit +export const WithUnit: Story = { + render: args => , + args: { + size: 'regular', + type: 'number', + value: '100', + unit: 'px', + placeholder: 'Enter value...', + }, +} + +// Email input +export const EmailInput: Story = { + render: args => , + args: { + size: 'regular', + type: 'email', + placeholder: 'Enter your email...', + showClearIcon: true, + }, +} + +// Password input +export const PasswordInput: Story = { + render: args => , + args: { + size: 'regular', + type: 'password', + placeholder: 'Enter password...', + value: 'secret123', + }, +} + +// Size comparison +const SizeComparisonDemo = () => { + const [regularValue, setRegularValue] = useState('') + const [largeValue, setLargeValue] = useState('') + + return ( +
+
+ + setRegularValue(e.target.value)} + placeholder="Regular input..." + showClearIcon + onClear={() => setRegularValue('')} + /> +
+
+ + setLargeValue(e.target.value)} + placeholder="Large input..." + showClearIcon + onClear={() => setLargeValue('')} + /> +
+
+ ) +} + +export const SizeComparison: Story = { + render: () => , +} + +// State comparison +const StateComparisonDemo = () => { + const [normalValue, setNormalValue] = useState('Normal state') + const [errorValue, setErrorValue] = useState('Error state') + + return ( +
+
+ + setNormalValue(e.target.value)} + showClearIcon + onClear={() => setNormalValue('')} + /> +
+
+ + setErrorValue(e.target.value)} + destructive + /> +
+
+ + undefined} + disabled + /> +
+
+ ) +} + +export const StateComparison: Story = { + render: () => , +} + +// Form example +const FormExampleDemo = () => { + const [formData, setFormData] = useState({ + name: '', + email: '', + age: '', + website: '', + }) + const [errors, setErrors] = useState({ + email: false, + age: false, + }) + + const validateEmail = (email: string) => { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) + } + + return ( +
+

User Profile

+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="Enter your name..." + showClearIcon + onClear={() => setFormData({ ...formData, name: '' })} + /> +
+
+ + { + setFormData({ ...formData, email: e.target.value }) + setErrors({ ...errors, email: e.target.value ? !validateEmail(e.target.value) : false }) + }} + placeholder="Enter your email..." + destructive={errors.email} + showClearIcon + onClear={() => { + setFormData({ ...formData, email: '' }) + setErrors({ ...errors, email: false }) + }} + /> + {errors.email && ( + Please enter a valid email address + )} +
+
+ + { + setFormData({ ...formData, age: e.target.value }) + setErrors({ ...errors, age: e.target.value ? Number(e.target.value) < 18 : false }) + }} + placeholder="Enter your age..." + destructive={errors.age} + unit="years" + /> + {errors.age && ( + Must be 18 or older + )} +
+
+ + setFormData({ ...formData, website: e.target.value })} + placeholder="https://example.com" + showClearIcon + onClear={() => setFormData({ ...formData, website: '' })} + /> +
+
+
+ ) +} + +export const FormExample: Story = { + render: () => , +} + +// Search example +const SearchExampleDemo = () => { + const [searchQuery, setSearchQuery] = useState('') + const items = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape'] + const filteredItems = items.filter(item => + item.toLowerCase().includes(searchQuery.toLowerCase()), + ) + + return ( +
+ setSearchQuery(e.target.value)} + onClear={() => setSearchQuery('')} + placeholder="Search fruits..." + /> + {searchQuery && ( +
+
+ {filteredItems.length} result{filteredItems.length !== 1 ? 's' : ''} +
+
+ {filteredItems.map(item => ( +
+ {item} +
+ ))} +
+
+ )} +
+ ) +} + +export const SearchExample: Story = { + render: () => , +} + +// Interactive playground +export const Playground: Story = { + render: args => , + args: { + size: 'regular', + type: 'text', + placeholder: 'Type something...', + disabled: false, + destructive: false, + showLeftIcon: false, + showClearIcon: true, + unit: '', + }, +} diff --git a/web/app/components/base/prompt-editor/index.stories.tsx b/web/app/components/base/prompt-editor/index.stories.tsx new file mode 100644 index 0000000000..17b04e4af0 --- /dev/null +++ b/web/app/components/base/prompt-editor/index.stories.tsx @@ -0,0 +1,360 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' + +// Mock component to avoid complex initialization issues +const PromptEditorMock = ({ value, onChange, placeholder, editable, compact, className, wrapperClassName }: any) => { + const [content, setContent] = useState(value || '') + + const handleChange = (e: React.ChangeEvent) => { + setContent(e.target.value) + onChange?.(e.target.value) + } + + return ( +
+