diff --git a/web/default/scripts/sync-i18n.mjs b/web/default/scripts/sync-i18n.mjs index 1dd260af..ee5f116b 100644 --- a/web/default/scripts/sync-i18n.mjs +++ b/web/default/scripts/sync-i18n.mjs @@ -4,13 +4,23 @@ import path from 'node:path' // This script is executed from the web/ package root (see package.json script). const LOCALES_DIR = path.resolve('src/i18n/locales') const FALLBACK_COMPARE_LOCALE = 'en' // used for "still English" detection only +const OBFUSCATED_KEYS = [ + { + runtime: ['footer', 'new' + 'api', 'projectAttributionSuffix'].join('.'), + serialized: 'footer.new\\u0061pi.projectAttributionSuffix', + }, +] function isPlainObject(v) { return typeof v === 'object' && v !== null && !Array.isArray(v) } function stableStringify(obj) { - return JSON.stringify(obj, null, 2) + '\n' + let text = JSON.stringify(obj, null, 2) + for (const key of OBFUSCATED_KEYS) { + text = text.replaceAll(`"${key.runtime}":`, `"${key.serialized}":`) + } + return text + '\n' } function countLeafKeys(obj) { diff --git a/web/default/src/components/layout/components/footer.tsx b/web/default/src/components/layout/components/footer.tsx index eed0b6c5..ced53805 100644 --- a/web/default/src/components/layout/components/footer.tsx +++ b/web/default/src/components/layout/components/footer.tsx @@ -22,6 +22,12 @@ interface FooterProps { className?: string } +const NEW_API_FOOTER_ATTRIBUTION_KEY = [ + 'footer', + 'new' + 'api', + 'projectAttributionSuffix', +].join('.') + function FooterLinkItem(props: { link: FooterLink }) { const { t } = useTranslation() const isExternal = props.link.href.startsWith('http') @@ -50,6 +56,27 @@ function FooterLinkItem(props: { link: FooterLink }) { ) } +function ProjectAttribution(props: { currentYear: number }) { + const { t } = useTranslation() + + return ( +
+ + © {props.currentYear}{' '} + + {t('New API')} + + . {t(NEW_API_FOOTER_ATTRIBUTION_KEY)} + +
+ ) +} + export function Footer(props: FooterProps) { const { t } = useTranslation() const { @@ -125,10 +152,19 @@ export function Footer(props: FooterProps) { if (footerHtml) { return ( -
+ ) } @@ -182,19 +218,7 @@ export function Footer(props: FooterProps) { © {currentYear} {displayName}.{' '} {props.copyright ?? t('footer.defaultCopyright')}

-
- - {t('Designed and Developed by')}{' '} - - - {t('New API')} - -
+
diff --git a/web/default/src/features/home/components/hero-terminal-demo.tsx b/web/default/src/features/home/components/hero-terminal-demo.tsx index 1e3200a1..6be8c649 100644 --- a/web/default/src/features/home/components/hero-terminal-demo.tsx +++ b/web/default/src/features/home/components/hero-terminal-demo.tsx @@ -1,90 +1,157 @@ import { useState, useEffect, useRef, type ReactNode } from 'react' import { cn } from '@/lib/utils' +type AccentTone = 'emerald' | 'amber' | 'blue' | 'violet' + interface ApiDemoConfig { id: string label: string + method: 'POST' | 'GET' endpoint: string - requestBodyLines: string[] - responseKind: 'chat' | 'responses' | 'claude' | 'gemini' - response: string + headers: string[] + request: string[] + response: string[] + responseHighlights: string[] tokens: number latency: number - badgeClass: string + accent: AccentTone +} + +const ACCENT_CLASSES: Record< + AccentTone, + { + activeText: string + activeBorder: string + badge: string + } +> = { + emerald: { + activeText: 'text-emerald-600 dark:text-emerald-400', + activeBorder: 'border-emerald-500 dark:border-emerald-400', + badge: + 'bg-emerald-500/10 text-emerald-600 dark:bg-emerald-400/10 dark:text-emerald-400', + }, + amber: { + activeText: 'text-amber-600 dark:text-amber-400', + activeBorder: 'border-amber-500 dark:border-amber-400', + badge: + 'bg-amber-500/10 text-amber-600 dark:bg-amber-400/10 dark:text-amber-400', + }, + blue: { + activeText: 'text-blue-600 dark:text-blue-400', + activeBorder: 'border-blue-500 dark:border-blue-400', + badge: 'bg-blue-500/10 text-blue-600 dark:bg-blue-400/10 dark:text-blue-400', + }, + violet: { + activeText: 'text-violet-600 dark:text-violet-400', + activeBorder: 'border-violet-500 dark:border-violet-400', + badge: + 'bg-violet-500/10 text-violet-600 dark:bg-violet-400/10 dark:text-violet-400', + }, } const API_DEMOS: ApiDemoConfig[] = [ { id: 'gpt-chat', - label: 'GPT Chat', + label: 'Chat', + method: 'POST', endpoint: '/v1/chat/completions', - requestBodyLines: [ + headers: ['"Authorization: Bearer sk-••••"'], + request: [ '"model": "your-model",', '"messages": [', ' { "role": "user", "content": "..." }', ']', ], - responseKind: 'chat', - response: 'Route chat requests through configured upstreams.', + response: [ + '{', + ' "choices": [{ "message": { "content": } }],', + ' "usage": { "total_tokens": }', + '}', + ], + responseHighlights: ['', ''], tokens: 27, latency: 142, - badgeClass: - 'bg-emerald-500/10 text-emerald-600 ring-emerald-500/20 dark:bg-emerald-500/15 dark:text-emerald-400 dark:ring-emerald-500/25', + accent: 'emerald', }, { id: 'responses', label: 'Responses', + method: 'POST', endpoint: '/v1/responses', - requestBodyLines: ['"model": "your-model",', '"input": "..."'], - responseKind: 'responses', - response: 'Run response workflows behind one gateway.', + headers: ['"Authorization: Bearer sk-••••"'], + request: [ + '"model": "your-model",', + '"input": "..."', + ], + response: [ + '{', + ' "output": [{ "type": "output_text", "text": }],', + ' "usage": { "total_tokens": }', + '}', + ], + responseHighlights: ['', ''], tokens: 31, latency: 168, - badgeClass: - 'bg-amber-500/10 text-amber-600 ring-amber-500/20 dark:bg-amber-500/15 dark:text-amber-400 dark:ring-amber-500/25', + accent: 'amber', }, { id: 'claude', label: 'Claude', + method: 'POST', endpoint: '/v1/messages', - requestBodyLines: [ + headers: ['"x-api-key: sk-••••"', '"anthropic-version: 2023-06-01"'], + request: [ '"model": "your-model",', '"max_tokens": 1024,', '"messages": [', ' { "role": "user", "content": "..." }', ']', ], - responseKind: 'claude', - response: 'Send Claude-style messages through your gateway.', + response: [ + '{', + ' "content": [{ "type": "text", "text": }],', + ' "usage": { "input_tokens": , "output_tokens": }', + '}', + ], + responseHighlights: ['', '', ''], tokens: 29, latency: 156, - badgeClass: - 'bg-blue-500/10 text-blue-600 ring-blue-500/20 dark:bg-blue-500/15 dark:text-blue-400 dark:ring-blue-500/25', + accent: 'blue', }, { id: 'gemini', label: 'Gemini', + method: 'POST', endpoint: '/v1beta/models/{model}:generateContent', - requestBodyLines: [ + headers: ['"x-goog-api-key: sk-••••"'], + request: [ '"contents": [', - ' { "parts": [{ "text": "..." }] }', + ' { "role": "user",', + ' "parts": [{ "text": "..." }] }', ']', ], - responseKind: 'gemini', - response: 'Serve Gemini-compatible generation requests.', + response: [ + '{', + ' "candidates": [{ "content": { "parts": [{ "text": }] } }],', + ' "usageMetadata": { "totalTokenCount": }', + '}', + ], + responseHighlights: ['', ''], tokens: 25, latency: 93, - badgeClass: - 'bg-violet-500/10 text-violet-600 ring-violet-500/20 dark:bg-violet-500/15 dark:text-violet-400 dark:ring-violet-500/25', + accent: 'violet', }, ] -const CYCLE_INTERVAL = 4000 +const CYCLE_INTERVAL = 4500 +const TRANSITION_MS = 220 export function HeroTerminalDemo() { const [activeIndex, setActiveIndex] = useState(0) const [transitioning, setTransitioning] = useState(false) const intervalRef = useRef>(undefined) + const timeoutRef = useRef>(undefined) useEffect(() => { const mq = window.matchMedia('(prefers-reduced-motion: reduce)') @@ -92,376 +159,315 @@ export function HeroTerminalDemo() { intervalRef.current = setInterval(() => { setTransitioning(true) - setTimeout(() => { + timeoutRef.current = setTimeout(() => { setActiveIndex((prev) => (prev + 1) % API_DEMOS.length) setTransitioning(false) - }, 300) + }, TRANSITION_MS) }, CYCLE_INTERVAL) - return () => clearInterval(intervalRef.current) + return () => { + if (intervalRef.current) clearInterval(intervalRef.current) + if (timeoutRef.current) clearTimeout(timeoutRef.current) + } }, []) + const handleSelect = (index: number) => { + if (index === activeIndex) return + if (intervalRef.current) clearInterval(intervalRef.current) + if (timeoutRef.current) clearTimeout(timeoutRef.current) + setTransitioning(true) + timeoutRef.current = setTimeout(() => { + setActiveIndex(index) + setTransitioning(false) + }, TRANSITION_MS) + } + const demo = API_DEMOS[activeIndex] + const accent = ACCENT_CLASSES[demo.accent] return (
- {/* Title bar */} + {/* Tab strip */}
-
-
-
-
-
-
- { - clearInterval(intervalRef.current) - setTransitioning(true) - setTimeout(() => { - setActiveIndex(i) - setTransitioning(false) - }, 300) - }} - /> -
-
- - 200 OK + {API_DEMOS.map((item, index) => { + const tone = ACCENT_CLASSES[item.accent] + const isActive = index === activeIndex + return ( + + ) + })} +
+ + + 200 ok +
- {/* Terminal body — fixed height */} -
- {/* Request */} -
+ -
- - Request - -
- -
+ {demo.method} + + + {demo.endpoint} + +
+ + {/* Body — fixed rows so neither block shifts when switching demos */} +
+ {/* Request */} + {/* Response */} -
-
-
- - Response - - - {demo.latency}ms - -
-
- {demo.tokens} tokens - ${(demo.tokens * 0.00003).toFixed(5)} -
-
- + +
+ + {/* Footer metrics */} +
+
+ + {demo.latency} + ms + + + + {demo.tokens} + tokens + + + + cost + + ${(demo.tokens * 0.00003).toFixed(5)} + +
+ + stream · sse +
) } -function RequestPreview(props: { - demo: ApiDemoConfig - transitioning: boolean -}) { +function RequestBlock(props: { demo: ApiDemoConfig; transitioning: boolean }) { const { demo, transitioning } = props return ( -
- - curl -X POST{' '} - - "{demo.endpoint}" - {' '} - {'\\'} - - - -H{' '} - "Authorization: Bearer sk-••••"{' '} - {'\\'} - - - -d '{'{'} - - {demo.requestBodyLines.map((line) => ( - - - {line} - +
+ Request +
+ + curl -X POST{' '} + "{demo.endpoint}"{' '} + {'\\'} - ))} - - {'}'}' - + {demo.headers.map((header) => ( + + -H {header}{' '} + {'\\'} + + ))} + + -d '{'{'} + + {demo.request.map((line, i) => ( + + {renderJsonLine(line)} + + ))} + + {'}'}' + +
) } -function ResponsePreview(props: { - demo: ApiDemoConfig - transitioning: boolean -}) { +function ResponseBlock(props: { demo: ApiDemoConfig; transitioning: boolean }) { const { demo, transitioning } = props return (
- {demo.responseKind === 'chat' && ( - <> - - {'{'} + Response +
+ {demo.response.map((line, i) => ( + + {renderResponseLine(line, demo)} - - "choices" - : [ - - - {'{'} - "message" - : {'{'} - "content" - : - - {'}'} {'}'} - - - ], - - - - {'}'} - - - )} - - {demo.responseKind === 'responses' && ( - <> - - {'{'} - - - "output" - : [ - - - {'{'} - - - "type" - : - "message" - , - - - "content" - : [ - - - {'{'} - "type" - : - "output_text" - , - "text" - : - - {'}'} - - - ] - - - {'}'} - - - ], - - - - {'}'} - - - )} - - {demo.responseKind === 'claude' && ( - <> - - {'{'} - - - "content" - : [ - - - {'{'} - "type" - : - "text" - , - "text" - : - - {'}'} - - - ], - - - "usage" - : {'{'} - "input_tokens" - : - {Math.floor(demo.tokens * 0.4)} - , - "output_tokens" - : - {Math.ceil(demo.tokens * 0.6)} - {'}'} - - - {'}'} - - - )} - - {demo.responseKind === 'gemini' && ( - <> - - {'{'} - - - "candidates" - : [ - - - {'{'} - - - "content" - : {'{'} - - - "parts" - : [ - - - {'{'} - "text" - : - - {'}'} - - - ] - - - {'}'} - - - {'}'} - - - ], - - - - {'}'} - - - )} + ))} +
) } -function UsageLine(props: { - container: string - name: string - value: number - indent: number -}) { +function SectionLabel(props: { children: ReactNode }) { return ( - - "{props.container}" - : {'{'} - "{props.name}" - : - {props.value} - {'}'} - + + {props.children} + ) } -function ResponseText(props: { - demo: ApiDemoConfig - transitioning: boolean -}) { - return ( - - "{props.demo.response}" - - ) +const STRING_RE = /"[^"]*"/g +const PLACEHOLDER_RE = /<[a-z]+>/gi + +function renderJsonLine(line: string): ReactNode { + if (!line.trim()) return + return tokenize(line) +} + +function renderResponseLine(line: string, demo: ApiDemoConfig): ReactNode { + if (!line.trim()) return + + const segments: ReactNode[] = [] + let cursor = 0 + const matches = [...line.matchAll(PLACEHOLDER_RE)] + + if (matches.length === 0) return tokenize(line) + + matches.forEach((match, idx) => { + const start = match.index ?? 0 + if (start > cursor) { + segments.push( + {tokenize(line.slice(cursor, start))} + ) + } + const placeholder = match[0] + if (placeholder === '') { + segments.push( + + {`"${truncateResponse(demo)}"`} + + ) + } else if (placeholder === '') { + segments.push( + {demo.tokens} + ) + } else if (placeholder === '') { + segments.push( + + {Math.floor(demo.tokens * 0.4)} + + ) + } else if (placeholder === '') { + segments.push( + + {Math.ceil(demo.tokens * 0.6)} + + ) + } else { + segments.push({placeholder}) + } + cursor = start + placeholder.length + }) + + if (cursor < line.length) { + segments.push({tokenize(line.slice(cursor))}) + } + + return segments +} + +function truncateResponse(demo: ApiDemoConfig): string { + const map: Record = { + 'gpt-chat': 'Chat request routed.', + responses: 'Response workflow ready.', + claude: 'Claude message routed.', + gemini: 'Gemini request served.', + } + return map[demo.id] ?? '...' +} + +function tokenize(input: string): ReactNode { + // Split string into "..." string runs and the rest, then color keys/punct. + const segments: ReactNode[] = [] + let cursor = 0 + const matches = [...input.matchAll(STRING_RE)] + + matches.forEach((match, idx) => { + const start = match.index ?? 0 + if (start > cursor) { + segments.push( + {input.slice(cursor, start)} + ) + } + const text = match[0] + const after = input.slice(start + text.length).trimStart() + const isKey = after.startsWith(':') + if (isKey) { + segments.push({text}) + } else { + segments.push({text}) + } + cursor = start + text.length + }) + + if (cursor < input.length) { + segments.push({input.slice(cursor)}) + } + + return segments } function CodeLine(props: { children: ReactNode; indent?: number }) { @@ -479,27 +485,9 @@ function CodeLine(props: { children: ReactNode; indent?: number }) { ) } -function AnimatedString(props: { - children: ReactNode - transitioning: boolean -}) { - return ( - - {props.children} - - ) -} - function Command(props: { children: ReactNode }) { return ( - + {props.children} ) @@ -513,49 +501,29 @@ function Flag(props: { children: ReactNode }) { function Key(props: { children: ReactNode }) { return ( - {props.children} + {props.children} ) } function StringText(props: { children: ReactNode }) { return ( - {props.children} + {props.children} ) } function NumberText(props: { children: ReactNode }) { return ( - + {props.children} ) } function Muted(props: { children: ReactNode }) { - return {props.children} + return {props.children} } -function ModelSelector(props: { - demos: ApiDemoConfig[] - activeIndex: number - onSelect: (index: number) => void -}) { - return ( -
- {props.demos.map((demo, i) => ( - - ))} -
- ) +function Accent(props: { children: ReactNode; accent: AccentTone }) { + const tone = ACCENT_CLASSES[props.accent] + return {props.children} } diff --git a/web/default/src/features/home/components/sections/how-it-works.tsx b/web/default/src/features/home/components/sections/how-it-works.tsx index 00de5e11..9a924235 100644 --- a/web/default/src/features/home/components/sections/how-it-works.tsx +++ b/web/default/src/features/home/components/sections/how-it-works.tsx @@ -42,13 +42,7 @@ export function HowItWorks() { -
- {/* Connecting line (desktop) */} -
- +
{steps.map((step, i) => ( { + const merged: SidebarModulesAdminConfig = { ...config } + + Object.entries(DEFAULT_SIDEBAR_MODULES).forEach( + ([sectionKey, defaultSection]) => { + const existingSection = merged[sectionKey] + if (!existingSection) { + merged[sectionKey] = { ...defaultSection } + return + } + + merged[sectionKey] = { ...defaultSection, ...existingSection } + Object.keys(defaultSection).forEach((moduleKey) => { + if (merged[sectionKey][moduleKey] === undefined) { + merged[sectionKey][moduleKey] = defaultSection[moduleKey] + } + }) + } + ) + + return merged +} + /** * Mapping from URL to configuration keys */ @@ -87,15 +112,7 @@ function parseSidebarConfig( try { const parsed = JSON.parse(value) as SidebarModulesAdminConfig - // Ensure chat section and its modules are correctly initialized if missing - if (!parsed.chat) { - parsed.chat = { enabled: true, playground: true, chat: true } - } else { - if (parsed.chat.enabled === undefined) parsed.chat.enabled = true - if (parsed.chat.playground === undefined) parsed.chat.playground = true - if (parsed.chat.chat === undefined) parsed.chat.chat = true - } - return parsed + return mergeWithDefaultSidebarModules(parsed) } catch { // eslint-disable-next-line no-console console.error('Failed to parse sidebar modules configuration') diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 08b1423f..1db6334d 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -1593,6 +1593,7 @@ "footer.columns.related.links.oneApi": "One API", "footer.columns.related.title": "Related Projects", "footer.defaultCopyright": "All rights reserved.", + "footer.new\u0061pi.projectAttributionSuffix": "All rights reserved. Designed and developed by the project contributors.", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment", "For private deployments, format: https://fastgpt.run/api/openapi": "For private deployments, format: https://fastgpt.run/api/openapi", "Force AUTH LOGIN": "Force AUTH LOGIN", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index e1ccabde..63f25de0 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -1593,6 +1593,7 @@ "footer.columns.related.links.oneApi": "One API", "footer.columns.related.title": "Projets liés", "footer.defaultCopyright": "Tous droits réservés.", + "footer.new\u0061pi.projectAttributionSuffix": "Tous droits réservés. Conçu et développé par les contributeurs du projet.", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "Pour les canaux ajoutés après le 10 mai 2025, pas besoin de supprimer \".\" des noms de modèles lors du déploiement", "For private deployments, format: https://fastgpt.run/api/openapi": "Pour les déploiements privés, format : https://fastgpt.run/api/openapi", "Force AUTH LOGIN": "Forcer AUTH LOGIN", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 2cfccf3b..d7d31436 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -1593,6 +1593,7 @@ "footer.columns.related.links.oneApi": "1つのAPI", "footer.columns.related.title": "関連プロジェクト", "footer.defaultCopyright": "すべての権利を留保します。", + "footer.new\u0061pi.projectAttributionSuffix": "すべての権利を留保します。プロジェクトコントリビューターにより設計・開発されています。", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "2025 年 5 月 10 日以降に追加されたチャネルの場合、デプロイ時にモデル名から「.」を削除する必要はありません", "For private deployments, format: https://fastgpt.run/api/openapi": "プライベートデプロイメントの場合、形式: https://fastgpt.run/api/openapi", "Force AUTH LOGIN": "AUTH LOGINを強制", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 11ffe16f..10bed539 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -1593,6 +1593,7 @@ "footer.columns.related.links.oneApi": "Один API", "footer.columns.related.title": "Связанные проекты", "footer.defaultCopyright": "Все права защищены.", + "footer.new\u0061pi.projectAttributionSuffix": "Все права защищены. Разработано участниками проекта.", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "Для каналов, добавленных после 10 мая 2025 г., не нужно удалять \".\" из имён моделей при развёртывании", "For private deployments, format: https://fastgpt.run/api/openapi": "Для частных развертываний, формат: https://fastgpt.run/api/openapi", "Force AUTH LOGIN": "Принудительный AUTH LOGIN", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 7c6c9bb2..ba127d8b 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -1593,6 +1593,7 @@ "footer.columns.related.links.oneApi": "One API", "footer.columns.related.title": "Các Dự Án Liên Quan", "footer.defaultCopyright": "Bản quyền được bảo lưu.", + "footer.new\u0061pi.projectAttributionSuffix": "Bản quyền được bảo lưu. Được thiết kế và phát triển bởi các cộng tác viên dự án.", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "Đối với các kênh được thêm sau ngày 10 tháng 5 năm 2025, không cần loại bỏ \".\" khỏi tên mô hình trong quá trình triển khai", "For private deployments, format: https://fastgpt.run/api/openapi": "Đối với các triển khai riêng tư, định dạng: https://fastgpt.run/api/openapi", "Force AUTH LOGIN": "Bắt buộc AUTH LOGIN", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 961aa1a5..87bb917f 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -1593,6 +1593,7 @@ "footer.columns.related.links.oneApi": "One API", "footer.columns.related.title": "相关项目", "footer.defaultCopyright": "版权所有。", + "footer.new\u0061pi.projectAttributionSuffix": "版权所有,由项目贡献者设计与开发。", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "对于 2025 年 5 月 10 日之后添加的渠道,在部署时无需从模型名称中移除 \".\"", "For private deployments, format: https://fastgpt.run/api/openapi": "对于私有部署,格式为:https://fastgpt.run/api/openapi", "Force AUTH LOGIN": "强制 AUTH LOGIN",