This commit is contained in:
SengokuCola
2026-04-25 00:09:41 +08:00
32 changed files with 2310 additions and 1235 deletions

View File

@@ -6,7 +6,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
export default tseslint.config( export default tseslint.config(
{ ignores: ['dist'] }, { ignores: ['dist', 'out'] },
jsxA11y.flatConfigs.recommended, jsxA11y.flatConfigs.recommended,
{ {
extends: [js.configs.recommended, ...tseslint.configs.recommended], extends: [js.configs.recommended, ...tseslint.configs.recommended],
@@ -25,10 +25,7 @@ export default tseslint.config(
acc[key] = 'warn' acc[key] = 'warn'
return acc return acc
}, {}), }, {}),
'react-refresh/only-export-components': [ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'warn',
{ allowConstantExport: true },
],
// 关闭或降级其他规则 // 关闭或降级其他规则
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'warn', '@typescript-eslint/no-unused-vars': 'warn',
@@ -37,4 +34,11 @@ export default tseslint.config(
'jsx-a11y/no-autofocus': 'warn', 'jsx-a11y/no-autofocus': 'warn',
}, },
}, },
{
files: ['**/*.d.ts'],
rules: {
// Ambient global declarations use `var` in TypeScript declaration files.
'no-var': 'off',
},
}
) )

View File

@@ -121,6 +121,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@react-spring/web": "10.0.3",
"@tanstack/react-router": "^1.140.0", "@tanstack/react-router": "^1.140.0",
"@tanstack/react-virtual": "^3.13.13", "@tanstack/react-virtual": "^3.13.13",
"@tanstack/router-devtools": "^1.140.0", "@tanstack/router-devtools": "^1.140.0",
@@ -130,6 +131,7 @@
"@uppy/dashboard": "^5.1.0", "@uppy/dashboard": "^5.1.0",
"@uppy/react": "^5.1.1", "@uppy/react": "^5.1.1",
"@uppy/xhr-upload": "^5.1.1", "@uppy/xhr-upload": "^5.1.1",
"@use-gesture/react": "^10.3.1",
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -142,6 +144,7 @@
"idb": "^8.0.3", "idb": "^8.0.3",
"katex": "^0.16.27", "katex": "^0.16.27",
"lucide-react": "^0.556.0", "lucide-react": "^0.556.0",
"motion": "^12.38.0",
"react": "^19.2.1", "react": "^19.2.1",
"react-day-picker": "^9.12.0", "react-day-picker": "^9.12.0",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
@@ -154,9 +157,7 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"smol-toml": "^1.5.2", "smol-toml": "^1.5.2",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0"
"@react-spring/web": "10.0.3",
"@use-gesture/react": "^10.3.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",

View File

@@ -1,19 +1,8 @@
import { useEffect, useState } from 'react' import { lazy, Suspense } from 'react'
import CodeMirror from '@uiw/react-codemirror'
import { css } from '@codemirror/lang-css'
import { json, jsonParseLinter } from '@codemirror/lang-json'
import { linter } from '@codemirror/lint'
import { python } from '@codemirror/lang-python'
import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView } from '@codemirror/view'
import { StreamLanguage } from '@codemirror/language'
import { toml as tomlMode } from '@codemirror/legacy-modes/mode/toml'
import { useTheme } from '@/components/use-theme'
export type Language = 'python' | 'json' | 'toml' | 'css' | 'text' export type Language = 'python' | 'json' | 'toml' | 'css' | 'text'
interface CodeEditorProps { export interface CodeEditorProps {
value: string value: string
onChange?: (value: string) => void onChange?: (value: string) => void
@@ -27,109 +16,38 @@ interface CodeEditorProps {
className?: string className?: string
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any const CodeEditorImpl = lazy(() => import('./CodeEditorImpl'))
const languageExtensions: Record<Language, any[]> = {
python: [python()],
json: [json(), linter(jsonParseLinter())],
toml: [StreamLanguage.define(tomlMode)],
css: [css()],
text: [],
}
export function CodeEditor({ function CodeEditorFallback({
value, height,
onChange,
language = 'text',
readOnly = false,
height = '400px',
minHeight, minHeight,
maxHeight, maxHeight,
placeholder,
theme,
className = '', className = '',
}: CodeEditorProps) { }: Pick<CodeEditorProps, 'height' | 'minHeight' | 'maxHeight' | 'className'>) {
const [mounted, setMounted] = useState(false) return (
const { resolvedTheme } = useTheme() <div
className={`bg-muted animate-pulse rounded-md border ${className}`}
style={{ height, minHeight, maxHeight }}
/>
)
}
useEffect(() => { export function CodeEditor(props: CodeEditorProps) {
setMounted(true) const { height = '400px', minHeight, maxHeight, className = '' } = props
}, [])
if (!mounted) {
return (
<div
className={`rounded-md border bg-muted animate-pulse ${className}`}
style={{ height, minHeight, maxHeight }}
/>
)
}
const extensions = [
...(languageExtensions[language] || []),
EditorView.lineWrapping,
// 应用 JetBrains Mono 字体
EditorView.theme({
'&': {
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
},
'.cm-content': {
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
},
'.cm-gutters': {
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
},
'.cm-scroller': {
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
},
}),
]
if (readOnly) {
extensions.push(EditorView.editable.of(false))
}
// 如果外部传了 theme prop 则使用,否则从 context 自动获取
const effectiveTheme = theme ?? resolvedTheme
return ( return (
<div className={`rounded-md overflow-hidden border custom-scrollbar ${className}`}> <Suspense
<CodeMirror fallback={
value={value} <CodeEditorFallback
height={height} height={height}
minHeight={minHeight} minHeight={minHeight}
maxHeight={maxHeight} maxHeight={maxHeight}
theme={effectiveTheme === 'dark' ? oneDark : undefined} className={className}
extensions={extensions} />
onChange={onChange} }
placeholder={placeholder} >
basicSetup={{ <CodeEditorImpl {...props} />
lineNumbers: true, </Suspense>
highlightActiveLineGutter: true,
highlightSpecialChars: true,
history: true,
foldGutter: true,
drawSelection: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
syntaxHighlighting: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
rectangularSelection: true,
crosshairCursor: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
defaultKeymap: true,
searchKeymap: true,
historyKeymap: true,
foldKeymap: true,
completionKeymap: true,
lintKeymap: true,
}}
/>
</div>
) )
} }

View File

@@ -0,0 +1,105 @@
import { css } from '@codemirror/lang-css'
import { json, jsonParseLinter } from '@codemirror/lang-json'
import { python } from '@codemirror/lang-python'
import { StreamLanguage } from '@codemirror/language'
import { toml as tomlMode } from '@codemirror/legacy-modes/mode/toml'
import { linter } from '@codemirror/lint'
import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView } from '@codemirror/view'
import CodeMirror from '@uiw/react-codemirror'
import { useTheme } from '@/components/use-theme'
import type { CodeEditorProps, Language } from './CodeEditor'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const languageExtensions: Record<Language, any[]> = {
python: [python()],
json: [json(), linter(jsonParseLinter())],
toml: [StreamLanguage.define(tomlMode)],
css: [css()],
text: [],
}
export default function CodeEditorImpl({
value,
onChange,
language = 'text',
readOnly = false,
height = '400px',
minHeight,
maxHeight,
placeholder,
theme,
className = '',
}: CodeEditorProps) {
const { resolvedTheme } = useTheme()
const extensions = [
...(languageExtensions[language] || []),
EditorView.lineWrapping,
// 应用 JetBrains Mono 字体
EditorView.theme({
'&': {
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
},
'.cm-content': {
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
},
'.cm-gutters': {
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
},
'.cm-scroller': {
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
},
}),
]
if (readOnly) {
extensions.push(EditorView.editable.of(false))
}
// 如果外部传了 theme prop 则使用,否则从 context 自动获取
const effectiveTheme = theme ?? resolvedTheme
return (
<div className={`custom-scrollbar overflow-hidden rounded-md border ${className}`}>
<CodeMirror
value={value}
height={height}
minHeight={minHeight}
maxHeight={maxHeight}
theme={effectiveTheme === 'dark' ? oneDark : undefined}
extensions={extensions}
onChange={onChange}
placeholder={placeholder}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightSpecialChars: true,
history: true,
foldGutter: true,
drawSelection: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
syntaxHighlighting: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
rectangularSelection: true,
crosshairCursor: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
defaultKeymap: true,
searchKeymap: true,
historyKeymap: true,
foldKeymap: true,
completionKeymap: true,
lintKeymap: true,
}}
/>
</div>
)
}

View File

@@ -1443,6 +1443,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
<div <div
className="relative w-full max-w-md h-[400px] flex items-center justify-center" className="relative w-full max-w-md h-[400px] flex items-center justify-center"
role="listbox" role="listbox"
tabIndex={0}
aria-label="待审核的表达方式" aria-label="待审核的表达方式"
aria-activedescendant={quickExpressions[quickCurrentIndex] ? `quick-expr-${quickExpressions[quickCurrentIndex].id}` : undefined} aria-activedescendant={quickExpressions[quickCurrentIndex] ? `quick-expr-${quickExpressions[quickCurrentIndex].id}` : undefined}
> >
@@ -1561,14 +1562,14 @@ if (isCurrent) {
</div> </div>
{/* 情景 */} {/* 情景 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"></label> <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"></div>
<div className="p-3 bg-muted/30 rounded-lg border border-border/50"> <div className="p-3 bg-muted/30 rounded-lg border border-border/50">
<p className="text-lg font-medium leading-relaxed">{expr.situation}</p> <p className="text-lg font-medium leading-relaxed">{expr.situation}</p>
</div> </div>
</div> </div>
{/* 风格 */} {/* 风格 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"></label> <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"></div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{expr.style.split(/[,]/).map((s, i) => ( {expr.style.split(/[,]/).map((s, i) => (
<Badge key={i} variant="secondary" className="font-normal"> <Badge key={i} variant="secondary" className="font-normal">
@@ -1614,14 +1615,14 @@ if (isCurrent) {
</div> </div>
{/* 情景 */} {/* 情景 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"></label> <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"></div>
<div className="p-3 bg-muted/30 rounded-lg border border-border/50"> <div className="p-3 bg-muted/30 rounded-lg border border-border/50">
<p className="text-lg font-medium leading-relaxed">{expr.situation}</p> <p className="text-lg font-medium leading-relaxed">{expr.situation}</p>
</div> </div>
</div> </div>
{/* 风格 */} {/* 风格 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"></label> <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"></div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{expr.style.split(/[,]/).map((s, i) => ( {expr.style.split(/[,]/).map((s, i) => (
<Badge key={i} variant="secondary" className="font-normal"> <Badge key={i} variant="secondary" className="font-normal">

View File

@@ -1,4 +1,18 @@
import { BookOpen, ChevronLeft, Globe, LogOut, Menu, Moon, Search, Server, Sun } from 'lucide-react' import { Link } from '@tanstack/react-router'
import {
BookOpen,
ChevronLeft,
Globe,
LogOut,
Menu,
MessageSquare,
Moon,
Search,
Server,
SlidersHorizontal,
Sun,
} from 'lucide-react'
import { LayoutGroup, motion } from 'motion/react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -13,14 +27,21 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { ShortcutKbd } from '@/components/ui/kbd' import { ShortcutKbd } from '@/components/ui/kbd'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { toggleThemeWithTransition } from '@/components/use-theme' import { toggleThemeWithTransition } from '@/components/use-theme'
import { useBackground } from '@/hooks/use-background' import { useBackground } from '@/hooks/use-background'
import { logout } from '@/lib/fetch-with-auth' import { logout } from '@/lib/fetch-with-auth'
import { isElectron } from '@/lib/runtime' import { isElectron } from '@/lib/runtime'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { WorkspaceMode } from './types'
const LANGUAGE_CODES = ['zh', 'en', 'ja', 'ko'] as const const LANGUAGE_CODES = ['zh', 'en', 'ja', 'ko'] as const
const LANGUAGE_NAMES: Record<typeof LANGUAGE_CODES[number], string> = { "zh": "中文", "en": "English", "ja": "日本語", "ko": "한국어" } const LANGUAGE_NAMES: Record<(typeof LANGUAGE_CODES)[number], string> = {
zh: '中文',
en: 'English',
ja: '日本語',
ko: '한국어',
}
interface HeaderProps { interface HeaderProps {
sidebarOpen: boolean sidebarOpen: boolean
@@ -31,6 +52,7 @@ interface HeaderProps {
onMobileMenuToggle: () => void onMobileMenuToggle: () => void
onSearchOpenChange: (open: boolean) => void onSearchOpenChange: (open: boolean) => void
onThemeChange: (theme: 'light' | 'dark' | 'system') => void onThemeChange: (theme: 'light' | 'dark' | 'system') => void
workspaceMode: WorkspaceMode
} }
export function Header({ export function Header({
@@ -42,6 +64,7 @@ export function Header({
onMobileMenuToggle, onMobileMenuToggle,
onSearchOpenChange, onSearchOpenChange,
onThemeChange, onThemeChange,
workspaceMode,
}: HeaderProps) { }: HeaderProps) {
const { t, i18n: i18nInstance } = useTranslation() const { t, i18n: i18nInstance } = useTranslation()
const currentLang = i18nInstance.language || 'zh' const currentLang = i18nInstance.language || 'zh'
@@ -62,10 +85,12 @@ export function Header({
} }
return ( return (
<header className={cn( <header
'sticky top-0 z-10 flex h-16 items-center justify-between border-b px-4 backdrop-blur-md isolate', className={cn(
inheritsPageBackground ? 'bg-transparent' : 'bg-card/80', 'sticky top-0 isolate z-10 flex h-16 items-center justify-between border-b px-4 backdrop-blur-md',
)}> inheritsPageBackground ? 'bg-transparent' : 'bg-card/80'
)}
>
{!inheritsPageBackground && <BackgroundLayer config={headerBg} layerId="header" />} {!inheritsPageBackground && <BackgroundLayer config={headerBg} layerId="header" />}
<div className="relative z-10 flex items-center gap-4"> <div className="relative z-10 flex items-center gap-4">
{/* 移动端菜单按钮 */} {/* 移动端菜单按钮 */}
@@ -73,7 +98,10 @@ export function Header({
onClick={onMobileMenuToggle} onClick={onMobileMenuToggle}
aria-label={t('a11y.closeMenu')} aria-label={t('a11y.closeMenu')}
aria-expanded={mobileMenuOpen} aria-expanded={mobileMenuOpen}
className="rounded-lg p-2 hover:bg-accent lg:hidden" className={cn(
'hover:bg-accent rounded-lg p-2 lg:hidden',
workspaceMode === 'chat' && 'hidden'
)}
> >
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
</button> </button>
@@ -83,7 +111,10 @@ export function Header({
onClick={onSidebarToggle} onClick={onSidebarToggle}
aria-label={sidebarOpen ? t('header.collapseSidebar') : t('header.expandSidebar')} aria-label={sidebarOpen ? t('header.collapseSidebar') : t('header.expandSidebar')}
aria-expanded={sidebarOpen} aria-expanded={sidebarOpen}
className="hidden rounded-lg p-2 hover:bg-accent lg:block" className={cn(
'hover:bg-accent hidden rounded-lg p-2 lg:block',
workspaceMode === 'chat' && 'lg:hidden'
)}
> >
<ChevronLeft <ChevronLeft
className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')} className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')}
@@ -92,6 +123,49 @@ export function Header({
</div> </div>
<div className="relative z-10 flex items-center gap-2"> <div className="relative z-10 flex items-center gap-2">
{/* 工作区切换:复用 Tabs 组件 + Motion 动画指示器 */}
<LayoutGroup id="workspace-switcher">
<Tabs value={workspaceMode} aria-label={t('workspace.switcherLabel')}>
<TabsList className="bg-background/60 relative h-9 gap-0.5 border p-1 shadow-sm backdrop-blur">
<TabsTrigger
asChild
value="settings"
className="relative h-7 gap-1.5 bg-transparent px-2.5 text-xs font-medium data-[state=active]:bg-transparent data-[state=active]:text-primary-foreground data-[state=active]:shadow-none"
>
<Link to="/">
{workspaceMode === 'settings' && (
<motion.span
layoutId="workspace-tab-pill"
className="bg-primary absolute inset-0 -z-10 rounded-md shadow-sm"
transition={{ type: 'spring', stiffness: 480, damping: 38, mass: 0.6 }}
/>
)}
<SlidersHorizontal className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('workspace.settings')}</span>
</Link>
</TabsTrigger>
<TabsTrigger
asChild
value="chat"
className="relative h-7 gap-1.5 bg-transparent px-2.5 text-xs font-medium data-[state=active]:bg-transparent data-[state=active]:text-primary-foreground data-[state=active]:shadow-none"
>
<Link to="/chat">
{workspaceMode === 'chat' && (
<motion.span
layoutId="workspace-tab-pill"
className="bg-primary absolute inset-0 -z-10 rounded-md shadow-sm"
transition={{ type: 'spring', stiffness: 480, damping: 38, mass: 0.6 }}
/>
)}
<MessageSquare className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('workspace.chat')}</span>
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
</LayoutGroup>
<div className="bg-border h-6 w-px" />
{/* 后端切换按钮(仅 Electron */} {/* 后端切换按钮(仅 Electron */}
{isElectron() && ( {isElectron() && (
<> <>
@@ -103,23 +177,30 @@ export function Header({
title={t('header.toggleConnection')} title={t('header.toggleConnection')}
> >
<Server className="h-4 w-4" /> <Server className="h-4 w-4" />
<span className="hidden sm:inline text-xs text-muted-foreground truncate max-w-25"> <span className="text-muted-foreground hidden max-w-25 truncate text-xs sm:inline">
{activeBackendName} {activeBackendName}
</span> </span>
</Button> </Button>
<BackendManager open={backendManagerOpen} onOpenChange={setBackendManagerOpen} /> <BackendManager open={backendManagerOpen} onOpenChange={setBackendManagerOpen} />
<div className="h-6 w-px bg-border" /> <div className="bg-border h-6 w-px" />
</> </>
)} )}
{/* 搜索框 */} {/* 搜索框 */}
<button <button
onClick={() => onSearchOpenChange(true)} onClick={() => onSearchOpenChange(true)}
aria-label={t('header.searchPlaceholder')} aria-label={t('header.searchPlaceholder')}
className="relative hidden md:flex items-center w-64 h-9 pl-9 pr-16 bg-background/50 border rounded-md hover:bg-accent/50 transition-colors text-left" className="bg-background/50 hover:bg-accent/50 relative hidden h-9 w-64 items-center rounded-md border pr-16 pl-9 text-left transition-colors md:flex"
> >
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" /> <Search
<span className="text-sm text-muted-foreground">{t('header.searchPlaceholder')}</span> className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
<ShortcutKbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2" keys={['mod', 'k']} /> aria-hidden="true"
/>
<span className="text-muted-foreground text-sm">{t('header.searchPlaceholder')}</span>
<ShortcutKbd
size="sm"
className="absolute top-1/2 right-2 -translate-y-1/2"
keys={['mod', 'k']}
/>
</button> </button>
{/* 搜索对话框 */} {/* 搜索对话框 */}
@@ -142,26 +223,23 @@ export function Header({
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2"> <Button variant="ghost" size="sm" className="gap-2">
<Globe className="h-4 w-4" /> <Globe className="h-4 w-4" />
<span className="hidden sm:inline text-xs"> <span className="hidden text-xs sm:inline">
{LANGUAGE_NAMES[currentLang.split('-')[0] as 'zh' | 'en' | 'ja' | 'ko'] ?? currentLang} {LANGUAGE_NAMES[currentLang.split('-')[0] as 'zh' | 'en' | 'ja' | 'ko'] ??
currentLang}
</span> </span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{ {LANGUAGE_CODES.map((code) => (
LANGUAGE_CODES.map((code) => (
<DropdownMenuItem <DropdownMenuItem
key={code} key={code}
onClick={() => i18nInstance.changeLanguage(code)} onClick={() => i18nInstance.changeLanguage(code)}
className={cn( className={cn(
'cursor-pointer', 'cursor-pointer',
currentLang.split('-')[0] === code && 'font-semibold text-primary' currentLang.split('-')[0] === code && 'text-primary font-semibold'
)} )}
> >
{currentLang.split('-')[0] === code && ( {currentLang.split('-')[0] === code && <span className="mr-2"></span>}
<span className="mr-2"></span>
)}
{LANGUAGE_NAMES[code]} {LANGUAGE_NAMES[code]}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
@@ -175,13 +253,13 @@ export function Header({
toggleThemeWithTransition(newTheme, onThemeChange, e) toggleThemeWithTransition(newTheme, onThemeChange, e)
}} }}
aria-label={actualTheme === 'dark' ? t('header.switchToLight') : t('header.switchToDark')} aria-label={actualTheme === 'dark' ? t('header.switchToLight') : t('header.switchToDark')}
className="rounded-lg p-2 hover:bg-accent" className="hover:bg-accent rounded-lg p-2"
> >
{actualTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />} {actualTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button> </button>
{/* 分隔线 */} {/* 分隔线 */}
<div className="h-6 w-px bg-border" /> <div className="bg-border h-6 w-px" />
{/* 登出按钮 */} {/* 登出按钮 */}
<Button <Button

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useRouter } from '@tanstack/react-router' import { useRouter, useRouterState } from '@tanstack/react-router'
import { AnimatePresence, motion } from 'motion/react'
import { BackgroundLayer } from '@/components/background-layer' import { BackgroundLayer } from '@/components/background-layer'
import { BackToTop } from '@/components/back-to-top' import { BackToTop } from '@/components/back-to-top'
@@ -25,7 +26,10 @@ export function Layout({ children }: LayoutProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { checking } = useAuthGuard() // 检查认证状态 const { checking } = useAuthGuard() // 检查认证状态
const router = useRouter() const router = useRouter()
const pathname = useRouterState({ select: (state) => state.location.pathname })
const announce = useAnnounce() const announce = useAnnounce()
const workspaceMode = pathname.startsWith('/chat') ? 'chat' : 'settings'
const isChatWorkspace = workspaceMode === 'chat'
const [sidebarOpen, setSidebarOpen] = useState(true) const [sidebarOpen, setSidebarOpen] = useState(true)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
@@ -68,12 +72,12 @@ export function Layout({ children }: LayoutProps) {
pathToLabel[item.path] = t(item.label) pathToLabel[item.path] = t(item.label)
} }
} }
pathToLabel['/chat'] = t('workspace.chat')
return router.subscribe('onResolved', () => { return router.subscribe('onResolved', () => {
const pageTitle = pathToLabel[router.state.location.pathname] ?? 'MaiBot Dashboard' const pageTitle = pathToLabel[router.state.location.pathname] ?? 'MaiBot Dashboard'
const fullTitle = pageTitle === 'MaiBot Dashboard' const fullTitle =
? 'MaiBot Dashboard' pageTitle === 'MaiBot Dashboard' ? 'MaiBot Dashboard' : `${pageTitle} — MaiBot Dashboard`
: `${pageTitle} — MaiBot Dashboard`
// 更新 document.title // 更新 document.title
document.title = fullTitle document.title = fullTitle
@@ -106,71 +110,129 @@ export function Layout({ children }: LayoutProps) {
// 认证检查中,显示加载状态 // 认证检查中,显示加载状态
if (checking) { if (checking) {
return ( return (
<div className="flex h-screen items-center justify-center bg-background"> <div className="bg-background flex h-screen items-center justify-center">
<div className="text-muted-foreground">{t('layout.verifyingLogin')}</div> <div className="text-muted-foreground">{t('layout.verifyingLogin')}</div>
</div> </div>
) )
} }
return ( return (
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<SkipNav /> <SkipNav />
{isElectron() && <TitleBar />} {isElectron() && <TitleBar />}
<div className={cn('relative isolate flex h-screen overflow-hidden', isElectron() && 'pt-8')}> <div className={cn('relative isolate flex h-screen overflow-hidden', isElectron() && 'pt-8')}>
<BackgroundLayer config={pageBg} layerId="page" /> <BackgroundLayer config={pageBg} layerId="page" />
<div className="relative z-10 flex h-full w-full overflow-hidden"> <div className="relative z-10 flex h-full w-full overflow-hidden">
{/* Sidebar */} {/* Sidebar:仅在设置工作区显示,伴随滑入/滑出动画 */}
<Sidebar <AnimatePresence initial={false}>
sidebarOpen={sidebarOpen} {!isChatWorkspace && (
mobileMenuOpen={mobileMenuOpen} <motion.div
tooltipsEnabled={tooltipsEnabled} key="settings-sidebar"
onMobileMenuClose={() => setMobileMenuOpen(false)} className="relative z-40 hidden shrink-0 lg:block"
/> initial={{ width: 0, opacity: 0 }}
animate={{ width: sidebarOpen ? 256 : 64, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{
type: 'spring',
stiffness: 320,
damping: 36,
mass: 0.7,
opacity: { duration: 0.2 },
}}
style={{ overflow: 'hidden' }}
>
<Sidebar
sidebarOpen={sidebarOpen}
mobileMenuOpen={mobileMenuOpen}
tooltipsEnabled={tooltipsEnabled}
onMobileMenuClose={() => setMobileMenuOpen(false)}
/>
</motion.div>
)}
</AnimatePresence>
{/* Mobile overlay */} {/* 移动端 Sidebar 走自己的 fixed 定位,通过 mobileMenuOpen 控制显隐 */}
{mobileMenuOpen && ( {!isChatWorkspace && (
<div <div className="lg:hidden">
aria-hidden="true" <Sidebar
className="fixed inset-0 z-40 bg-black/50 lg:hidden" sidebarOpen={sidebarOpen}
onClick={() => setMobileMenuOpen(false)} mobileMenuOpen={mobileMenuOpen}
/> tooltipsEnabled={tooltipsEnabled}
onMobileMenuClose={() => setMobileMenuOpen(false)}
)} />
{/* Main content */} </div>
<div className="flex flex-1 flex-col overflow-hidden">
{/* HTTP 安全警告横幅 */}
<HttpWarningBanner />
{/* Topbar */}
<Header
sidebarOpen={sidebarOpen}
mobileMenuOpen={mobileMenuOpen}
searchOpen={searchOpen}
actualTheme={actualTheme}
onSidebarToggle={() => setSidebarOpen(!sidebarOpen)}
onMobileMenuToggle={() => setMobileMenuOpen(!mobileMenuOpen)}
onSearchOpenChange={setSearchOpen}
onThemeChange={setTheme}
/>
{/* Page content */}
<main
id="main-content"
tabIndex={-1}
className={cn(
'relative isolate flex-1 overflow-hidden outline-none',
pageBg.type === 'none' ? 'bg-background' : 'bg-transparent',
)} )}
>
<div className="relative z-10 h-full">
{children}
</div>
</main>
{/* Back to Top Button */} {/* Mobile overlay */}
<BackToTop /> <AnimatePresence>
</div> {!isChatWorkspace && mobileMenuOpen && (
</div> <motion.div
aria-hidden="true"
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={() => setMobileMenuOpen(false)}
/>
)}
</AnimatePresence>
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* HTTP 安全警告横幅 */}
<HttpWarningBanner />
{/* Topbar */}
<Header
sidebarOpen={sidebarOpen}
mobileMenuOpen={mobileMenuOpen}
searchOpen={searchOpen}
actualTheme={actualTheme}
onSidebarToggle={() => setSidebarOpen(!sidebarOpen)}
onMobileMenuToggle={() => setMobileMenuOpen(!mobileMenuOpen)}
onSearchOpenChange={setSearchOpen}
onThemeChange={setTheme}
workspaceMode={workspaceMode}
/>
{/* Page content */}
<main
id="main-content"
tabIndex={-1}
className={cn(
'relative isolate flex-1 overflow-hidden outline-none',
isChatWorkspace
? 'bg-transparent'
: pageBg.type === 'none'
? 'bg-background'
: 'bg-transparent'
)}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={workspaceMode}
className="relative z-10 h-full"
initial={{ opacity: 0, x: isChatWorkspace ? 32 : -32, filter: 'blur(6px)' }}
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, x: isChatWorkspace ? -32 : 32, filter: 'blur(6px)' }}
transition={{
type: 'spring',
stiffness: 320,
damping: 34,
mass: 0.7,
opacity: { duration: 0.18 },
filter: { duration: 0.22 },
}}
>
{children}
</motion.div>
</AnimatePresence>
</main>
{/* Back to Top Button */}
{!isChatWorkspace && <BackToTop />}
</div>
</div>
</div> </div>
</TooltipProvider> </TooltipProvider>
) )

View File

@@ -37,7 +37,6 @@ export const menuSections: MenuSection[] = [
{ icon: Sliders, label: 'sidebar.menu.pluginConfig', path: '/plugin-config' }, { icon: Sliders, label: 'sidebar.menu.pluginConfig', path: '/plugin-config' },
{ icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' }, { icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' },
{ icon: Activity, label: 'sidebar.menu.maisakaMonitor', path: '/planner-monitor' }, { icon: Activity, label: 'sidebar.menu.maisakaMonitor', path: '/planner-monitor' },
{ icon: MessageSquare, label: 'sidebar.menu.localChat', path: '/chat' },
], ],
}, },
{ {

View File

@@ -5,6 +5,8 @@ export interface LayoutProps {
children: ReactNode children: ReactNode
} }
export type WorkspaceMode = 'settings' | 'chat'
export interface MenuItem { export interface MenuItem {
icon: ComponentType<LucideProps> icon: ComponentType<LucideProps>
label: string label: string

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { ListFieldEditor } from '@/components' import { ListFieldEditor } from '@/components/ListFieldEditor'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'

View File

@@ -43,11 +43,122 @@
"settings": "Settings" "settings": "Settings"
} }
}, },
"workspace": {
"switcherLabel": "Switch MaiBot workspace",
"settings": "Mai Settings",
"chat": "Mai Chat"
},
"chat": {
"defaultTab": "WebUI",
"botNameFallback": "Mai",
"userFallback": "User",
"userNameFallback": "WebUI User",
"virtualGroupFallback": "WebUI Virtual Group",
"status": {
"connected": "Connected",
"connecting": "Connecting...",
"disconnected": "Disconnected"
},
"actions": {
"cancel": "Cancel",
"reconnect": "Reconnect",
"save": "Save",
"send": "Send message"
},
"identity": {
"current": "Current identity:",
"editName": "Edit nickname",
"group": "Group: {{group}}",
"namePlaceholder": "Enter nickname",
"virtual": "Virtual identity:"
},
"input": {
"placeholder": "Type a message...",
"waiting": "Waiting for connection..."
},
"message": {
"empty": "Start chatting with {{bot}}!",
"emptyHint": "Type a message below and press Enter to send",
"errorFallback": "An error occurred",
"thinking": "Thinking..."
},
"composer": {
"hint": "Enter to send · Shift + Enter for newline"
},
"sidebar": {
"closeConversation": "Close {{label}}",
"conversations": "Chat conversations",
"emptyPreview": "No messages yet",
"identityHint": "Local chat identity",
"newVirtual": "New virtual identity chat",
"online": "Online",
"offline": "Offline",
"subtitle": "{{count}} conversations",
"title": "Chats",
"webuiBadge": "WebUI",
"virtualBadge": "Virtual",
"profileTitle": "My identity",
"editName": "Edit nickname",
"saveName": "Save"
},
"dialog": {
"create": "Create chat",
"description": "Choose a user MaiBot already knows and chat as that user. MaiBot will respond using her memory and understanding of that user.",
"groupName": "Virtual group name (optional)",
"groupNameHint": "MaiBot will treat this as a group chat with this name",
"knownUserSuffix": " · Known",
"loading": "Loading...",
"noUsers": "No users found",
"personCount": "({{count}} people)",
"platform": "Select platform",
"platformPlaceholder": "Select platform",
"searchUser": "Search user...",
"title": "New virtual identity chat",
"user": "Select user"
},
"media": {
"audioUnsupported": "Your browser does not support audio playback",
"emoji": "Emoji",
"face": "[Face: {{data}}]",
"file": "[File: {{data}}]",
"forward": "[Forwarded message]",
"image": "Image",
"loadFailed": "[{{type}} failed to load]",
"music": "[Music share]",
"noCaptions": "No captions",
"reply": "[Reply]",
"unknown": "[{{type}}]",
"unknownMessage": "Unknown message",
"videoUnsupported": "Your browser does not support video playback"
},
"toast": {
"backendUnavailable": "Cannot connect to the backend. Make sure MaiBot is running.",
"backendUnavailableShort": "Cannot connect to the backend",
"connectionFailed": "Connection failed",
"currentSessionUnavailable": "The current chat session is unavailable. Please try again later.",
"error": "Error",
"incompleteConfig": "Incomplete configuration",
"networkError": "Network error",
"platformFailed": "Failed to load platforms",
"selectPlatformAndUser": "Please select a platform and user",
"sendFailed": "Send failed",
"serverError": "Server returned error: {{status}}",
"sessionUnavailable": "Cannot open the chat session. Please try again later.",
"virtualTabCreated": "Virtual identity tab",
"virtualTabCreatedDesc": "Created a chat for {{label}}"
}
},
"layout": { "layout": {
"verifyingLogin": "Verifying login status...", "verifyingLogin": "Verifying login status...",
"logoTitle": "MaiBot WebUI", "logoTitle": "MaiBot WebUI",
"logoTitleShort": "M" "logoTitleShort": "M"
}, },
"httpWarning": {
"title": "Security notice: ",
"message": "You are accessing WebUI over HTTP.",
"description": "Non-local HTTP connections may expose access tokens and configuration data. Use HTTPS or a trusted private network when possible.",
"dismiss": "Dismiss HTTP security notice"
},
"settings": { "settings": {
"title": "Settings", "title": "Settings",
"description": "Manage your application preferences", "description": "Manage your application preferences",
@@ -395,7 +506,7 @@
}, },
"other": { "other": {
"title": "Other Settings", "title": "Other Settings",
"description": "Configure tools, emotion system, and more" "description": "Configure global slang and other basic options"
}, },
"siliconFlow": { "siliconFlow": {
"title": "API Setup", "title": "API Setup",
@@ -450,9 +561,12 @@
"personality": { "personality": {
"personality": "She is a sophomore college student who spends time on Tieba.", "personality": "She is a sophomore college student who spends time on Tieba.",
"replyStyle": "Keep replies plain and concise, speak Chinese, and avoid deliberately emphasizing any academic background. You can refer to the reply styles commonly seen on Tieba, Zhihu, and Weibo.", "replyStyle": "Keep replies plain and concise, speak Chinese, and avoid deliberately emphasizing any academic background. You can refer to the reply styles commonly seen on Tieba, Zhihu, and Weibo.",
"interest": "Interested in technology, games, anime, and everyday topics, and dislikes topics that are overly heavy or serious.", "multipleReplyStyles": {
"planStyle": "1. Consider **every action** in **all** available actions and use it if its conditions match the current conversation.\n2. Do not repeat content that has already been executed.\n3. Control how often you speak and avoid replying too frequently.\n4. If someone seems annoyed with you, reduce your replies.\n5. If someone attacks you or becomes emotional, respond appropriately.", "plain": "Your style is plain, lightly sarcastic, very short, and colloquial. You can reference Tieba and Weibo reply styles.",
"privatePlanStyle": "1. Consider **every action** in **all** available actions and use it if its conditions match the current conversation.\n2. Do not repeat content that has already been executed.\n3. If a sentence has already been replied to, do not reply to it again." "shortText": "Reply with 1-2 words",
"shortSymbol": "Reply with 1-2 symbols",
"translation": "Use a slightly translated tone, but keep it short"
}
}, },
"emoji": { "emoji": {
"filtrationPrompt": "Appropriate and safe for general audiences" "filtrationPrompt": "Appropriate and safe for general audiences"
@@ -506,26 +620,20 @@
"placeholder": "Describe how the bot speaks and expresses itself", "placeholder": "Describe how the bot speaks and expresses itself",
"description": "Example: keep replies plain and concise, speak Chinese, and refer to styles seen on Tieba, Zhihu, and Weibo" "description": "Example: keep replies plain and concise, speak Chinese, and refer to styles seen on Tieba, Zhihu, and Weibo"
}, },
"interest": { "multipleReplyStyle": {
"label": "Interests *", "label": "Alternate Reply Styles",
"placeholder": "Describe the topics the bot is interested in", "placeholder": "Enter one alternate reply style per line",
"description": "This affects which topics the bot is more likely to respond to" "description": "When this list is not empty, MaiBot may randomly replace the default reply style using the probability below"
}, },
"planStyle": { "multipleProbability": {
"label": "Group Chat Rules *", "label": "Alternate Style Probability",
"placeholder": "The bot's behavior style and rules in group chats", "description": "The chance of randomly replacing the default reply style each time a reply is built"
"description": "Defines how the bot acts in group chats, such as reply frequency and conditions"
},
"privatePlanStyle": {
"label": "Private Chat Rules *",
"placeholder": "The bot's behavior style and rules in private chats",
"description": "Defines how the bot behaves in private chats"
} }
}, },
"emoji": { "emoji": {
"emojiChance": { "emojiSendNum": {
"label": "Emoji Activation Chance", "label": "Emoji Candidate Count",
"description": "How likely the bot is to send an emoji" "description": "How many emojis to choose from before sending. Maximum is 64"
}, },
"maxRegNum": { "maxRegNum": {
"label": "Maximum Emoji Count", "label": "Maximum Emoji Count",
@@ -554,10 +662,6 @@
} }
}, },
"other": { "other": {
"enableTool": {
"label": "Enable Tool System",
"description": "Allow the bot to use tools to enhance its capabilities"
},
"allGlobal": { "allGlobal": {
"label": "Enable Global Slang Mode", "label": "Enable Global Slang Mode",
"description": "Allow the bot to learn and use group-specific slang" "description": "Allow the bot to learn and use group-specific slang"

View File

@@ -43,11 +43,122 @@
"settings": "設定" "settings": "設定"
} }
}, },
"workspace": {
"switcherLabel": "麦麦ワークスペースを切り替え",
"settings": "麦麦設定",
"chat": "麦麦チャット"
},
"chat": {
"defaultTab": "WebUI",
"botNameFallback": "麦麦",
"userFallback": "ユーザー",
"userNameFallback": "WebUIユーザー",
"virtualGroupFallback": "WebUI仮想グループチャット",
"status": {
"connected": "接続済み",
"connecting": "接続中...",
"disconnected": "未接続"
},
"actions": {
"cancel": "キャンセル",
"reconnect": "再接続",
"save": "保存",
"send": "メッセージを送信"
},
"identity": {
"current": "現在の身份:",
"editName": "ニックネームを変更",
"group": "グループ:{{group}}",
"namePlaceholder": "ニックネームを入力",
"virtual": "仮想身份:"
},
"input": {
"placeholder": "メッセージを入力...",
"waiting": "接続待ち..."
},
"message": {
"empty": "{{bot}} と会話を始めましょう!",
"emptyHint": "下の入力欄にメッセージを入力し、Enter キーで送信",
"errorFallback": "エラーが発生しました",
"thinking": "考え中..."
},
"composer": {
"hint": "Enter で送信・Shift + Enter で改行"
},
"sidebar": {
"closeConversation": "{{label}} を閉じる",
"conversations": "チャット会話",
"emptyPreview": "まだメッセージはありません",
"identityHint": "ローカルチャット身份",
"newVirtual": "仮想身份チャットを作成",
"online": "オンライン",
"offline": "オフライン",
"subtitle": "{{count}} 件の会話",
"title": "チャット",
"webuiBadge": "WebUI",
"virtualBadge": "仮想",
"profileTitle": "自分の身份",
"editName": "ニックネームを編集",
"saveName": "保存"
},
"dialog": {
"create": "会話を作成",
"description": "麦麦がすでに知っているユーザーを選び、そのユーザーとして麦麦と会話します。麦麦はそのユーザーへの記憶と理解を使って返信します。",
"groupName": "仮想グループ名(任意)",
"groupNameHint": "麦麦はこれをこの名前のグループチャットとして認識します",
"knownUserSuffix": " · 既知",
"loading": "読み込み中...",
"noUsers": "ユーザーが見つかりません",
"personCount": "({{count}} 人)",
"platform": "プラットフォームを選択",
"platformPlaceholder": "プラットフォームを選択",
"searchUser": "ユーザー名を検索...",
"title": "仮想身份チャットを作成",
"user": "ユーザーを選択"
},
"media": {
"audioUnsupported": "お使いのブラウザは音声再生に対応していません",
"emoji": "絵文字",
"face": "[表情:{{data}}]",
"file": "[ファイル: {{data}}]",
"forward": "[転送メッセージ]",
"image": "画像",
"loadFailed": "[{{type}}の読み込みに失敗]",
"music": "[音楽共有]",
"noCaptions": "字幕なし",
"reply": "[返信メッセージ]",
"unknown": "[{{type}}]",
"unknownMessage": "不明なメッセージ",
"videoUnsupported": "お使いのブラウザは動画再生に対応していません"
},
"toast": {
"backendUnavailable": "バックエンドサービスに接続できません。MaiBot が起動しているか確認してください",
"backendUnavailableShort": "バックエンドサービスに接続できません",
"connectionFailed": "接続に失敗しました",
"currentSessionUnavailable": "現在のチャットセッションは利用できません。しばらくしてから再試行してください",
"error": "エラー",
"incompleteConfig": "設定が未完了です",
"networkError": "ネットワークエラー",
"platformFailed": "プラットフォームの取得に失敗しました",
"selectPlatformAndUser": "プラットフォームとユーザーを選択してください",
"sendFailed": "送信に失敗しました",
"serverError": "サーバーエラー: {{status}}",
"sessionUnavailable": "チャットセッションを開始できません。しばらくしてから再試行してください",
"virtualTabCreated": "仮想身份タブ",
"virtualTabCreatedDesc": "{{label}} の会話を作成しました"
}
},
"layout": { "layout": {
"verifyingLogin": "ログイン状態を確認中...", "verifyingLogin": "ログイン状態を確認中...",
"logoTitle": "MaiBot WebUI", "logoTitle": "MaiBot WebUI",
"logoTitleShort": "M" "logoTitleShort": "M"
}, },
"httpWarning": {
"title": "セキュリティ通知:",
"message": "WebUI に HTTP でアクセスしています。",
"description": "ローカル以外の HTTP 接続では、アクセストークンや設定内容が漏えいする可能性があります。可能であれば HTTPS または信頼できる内網接続を使用してください。",
"dismiss": "HTTP セキュリティ通知を閉じる"
},
"settings": { "settings": {
"title": "設定", "title": "設定",
"description": "アプリの設定を管理する", "description": "アプリの設定を管理する",
@@ -395,7 +506,7 @@
}, },
"other": { "other": {
"title": "その他の設定", "title": "その他の設定",
"description": "ツールや感情システムなどを設定します" "description": "グローバルスラングなどの基本オプションを設定します"
}, },
"siliconFlow": { "siliconFlow": {
"title": "API設定", "title": "API設定",
@@ -450,9 +561,12 @@
"personality": { "personality": {
"personality": "女子大生で、現在大学2年生。掲示板を見るのが好き。", "personality": "女子大生で、現在大学2年生。掲示板を見るのが好き。",
"replyStyle": "返信は淡々と、短めにし、中国語で話してください。自分の学科背景をわざと強調しないでください。Tieba、Zhihu、Weibo の返信スタイルを参考にできます。", "replyStyle": "返信は淡々と、短めにし、中国語で話してください。自分の学科背景をわざと強調しないでください。Tieba、Zhihu、Weibo の返信スタイルを参考にできます。",
"interest": "技術、ゲーム、アニメ、日常の話題に興味があり、重すぎたり厳粛すぎたりする話題は好みません。", "multipleReplyStyles": {
"planStyle": "1. 利用可能な **すべて** の action の **各アクション** が現在の条件に合うか考え、会話内容に合えば使用してください\n2. 同じ内容がすでに実行されている場合は繰り返さないでください\n3. 発言頻度を調整し、発言しすぎないでください\n4. 誰かがあなたにうんざりしている場合は、返信を減らしてください\n5. 誰かがあなたを攻撃したり感情的になったりした場合は、適切に対応してください", "plain": "淡々として少し皮肉っぽく、とても短く口語的に返信します。Tieba や Weibo の返信スタイルを参考にできます。",
"privatePlanStyle": "1. 利用可能な **すべて** の action の **各アクション** が現在の条件に合うか考え、会話内容に合えば使用してください\n2. 同じ内容がすでに実行されている場合は繰り返さないでください\n3. すでに返信した文には再度返信しないでください" "shortText": "1〜2文字で返信する",
"shortSymbol": "1〜2個の記号で返信する",
"translation": "少し翻訳調にするが、長くしない"
}
}, },
"emoji": { "emoji": {
"filtrationPrompt": "公序良俗に反しないこと" "filtrationPrompt": "公序良俗に反しないこと"
@@ -506,26 +620,20 @@
"placeholder": "ボットの話し方や表現の癖を説明してください", "placeholder": "ボットの話し方や表現の癖を説明してください",
"description": "例返信は淡々と短めにし、中国語で話し、Tieba・Zhihu・Weibo の雰囲気を参考にする" "description": "例返信は淡々と短めにし、中国語で話し、Tieba・Zhihu・Weibo の雰囲気を参考にする"
}, },
"interest": { "multipleReplyStyle": {
"label": "興味 *", "label": "代替返信スタイル",
"placeholder": "ボットが興味を持つ話題を説明してください", "placeholder": "1行につき1つの代替返信スタイルを入力",
"description": "どの話題に返信しやすくなるかに影響します" "description": "リストが空でない場合、MaiBot は下の確率でデフォルトの返信スタイルをランダムに置き換えます"
}, },
"planStyle": { "multipleProbability": {
"label": "グループチャットのルール *", "label": "代替スタイルの発動確率",
"placeholder": "グループチャットでの行動方針やルール", "description": "返信を構築するたびに、代替返信スタイルでデフォルトを置き換える確率です"
"description": "返信頻度や条件など、グループチャットでの振る舞いを定義します"
},
"privatePlanStyle": {
"label": "個別チャットのルール *",
"placeholder": "個別チャットでの行動方針やルール",
"description": "個別チャットでの振る舞いを定義します"
} }
}, },
"emoji": { "emoji": {
"emojiChance": { "emojiSendNum": {
"label": "絵文字パック発動確率", "label": "絵文字候補数",
"description": "ボットが絵文字を送る確率です" "description": "送信前にいくつの絵文字から選ぶかを指定します。最大 64 です"
}, },
"maxRegNum": { "maxRegNum": {
"label": "最大絵文字数", "label": "最大絵文字数",
@@ -554,10 +662,6 @@
} }
}, },
"other": { "other": {
"enableTool": {
"label": "ツールシステムを有効にする",
"description": "ボットが各種ツールを使って機能を拡張できるようにします"
},
"allGlobal": { "allGlobal": {
"label": "グローバルスラングモードを有効にする", "label": "グローバルスラングモードを有効にする",
"description": "グループ内のスラングを学習して使えるようにします" "description": "グループ内のスラングを学習して使えるようにします"

View File

@@ -43,11 +43,122 @@
"settings": "설정" "settings": "설정"
} }
}, },
"workspace": {
"switcherLabel": "MaiBot 작업 공간 전환",
"settings": "麦麦 설정",
"chat": "麦麦 채팅"
},
"chat": {
"defaultTab": "WebUI",
"botNameFallback": "麦麦",
"userFallback": "사용자",
"userNameFallback": "WebUI 사용자",
"virtualGroupFallback": "WebUI 가상 그룹 채팅",
"status": {
"connected": "연결됨",
"connecting": "연결 중...",
"disconnected": "연결 안됨"
},
"actions": {
"cancel": "취소",
"reconnect": "다시 연결",
"save": "저장",
"send": "메시지 보내기"
},
"identity": {
"current": "현재 신분:",
"editName": "닉네임 수정",
"group": "그룹: {{group}}",
"namePlaceholder": "닉네임 입력",
"virtual": "가상 신분:"
},
"input": {
"placeholder": "메시지 입력...",
"waiting": "연결 대기 중..."
},
"message": {
"empty": "{{bot}}와 대화를 시작해 보세요!",
"emptyHint": "아래 입력란에 메시지를 입력하고 Enter 키를 눌러 보내세요",
"errorFallback": "오류가 발생했습니다",
"thinking": "생각 중..."
},
"composer": {
"hint": "Enter 전송 · Shift + Enter 줄바꿈"
},
"sidebar": {
"closeConversation": "{{label}} 닫기",
"conversations": "채팅 대화",
"emptyPreview": "아직 메시지가 없습니다",
"identityHint": "로컬 채팅 신분",
"newVirtual": "새 가상 신분 대화",
"online": "온라인",
"offline": "오프라인",
"subtitle": "대화 {{count}}개",
"title": "채팅",
"webuiBadge": "WebUI",
"virtualBadge": "가상",
"profileTitle": "내 신분",
"editName": "닉네임 편집",
"saveName": "저장"
},
"dialog": {
"create": "대화 만들기",
"description": "麦麦가 이미 알고 있는 사용자를 선택해 그 사용자 신분으로 대화합니다. 麦麦는 해당 사용자에 대한 기억과 이해를 사용해 응답합니다.",
"groupName": "가상 그룹 이름(선택)",
"groupNameHint": "麦麦는 이 이름의 그룹 채팅으로 인식합니다",
"knownUserSuffix": " · 알고 있음",
"loading": "불러오는 중...",
"noUsers": "사용자를 찾을 수 없습니다",
"personCount": "({{count}}명)",
"platform": "플랫폼 선택",
"platformPlaceholder": "플랫폼 선택",
"searchUser": "사용자 이름 검색...",
"title": "새 가상 신분 대화",
"user": "사용자 선택"
},
"media": {
"audioUnsupported": "브라우저가 오디오 재생을 지원하지 않습니다",
"emoji": "이모티콘",
"face": "[표정:{{data}}]",
"file": "[파일: {{data}}]",
"forward": "[전달 메시지]",
"image": "이미지",
"loadFailed": "[{{type}} 로드 실패]",
"music": "[음악 공유]",
"noCaptions": "자막 없음",
"reply": "[답장]",
"unknown": "[{{type}}]",
"unknownMessage": "알 수 없는 메시지",
"videoUnsupported": "브라우저가 비디오 재생을 지원하지 않습니다"
},
"toast": {
"backendUnavailable": "백엔드 서비스에 연결할 수 없습니다. MaiBot이 실행 중인지 확인하세요.",
"backendUnavailableShort": "백엔드 서비스에 연결할 수 없습니다",
"connectionFailed": "연결 실패",
"currentSessionUnavailable": "현재 채팅 세션을 사용할 수 없습니다. 잠시 후 다시 시도하세요.",
"error": "오류",
"incompleteConfig": "설정이 완전하지 않습니다",
"networkError": "네트워크 오류",
"platformFailed": "플랫폼을 불러오지 못했습니다",
"selectPlatformAndUser": "플랫폼과 사용자를 선택하세요",
"sendFailed": "전송 실패",
"serverError": "서버 오류: {{status}}",
"sessionUnavailable": "채팅 세션을 열 수 없습니다. 잠시 후 다시 시도하세요.",
"virtualTabCreated": "가상 신분 탭",
"virtualTabCreatedDesc": "{{label}} 대화를 만들었습니다"
}
},
"layout": { "layout": {
"verifyingLogin": "로그인 상태 확인 중...", "verifyingLogin": "로그인 상태 확인 중...",
"logoTitle": "MaiBot WebUI", "logoTitle": "MaiBot WebUI",
"logoTitleShort": "M" "logoTitleShort": "M"
}, },
"httpWarning": {
"title": "보안 알림: ",
"message": "현재 HTTP로 WebUI에 접속하고 있습니다.",
"description": "로컬이 아닌 HTTP 연결에서는 액세스 토큰과 설정 내용이 노출될 수 있습니다. 가능하면 HTTPS 또는 신뢰할 수 있는 내부망 연결을 사용하세요.",
"dismiss": "HTTP 보안 알림 닫기"
},
"settings": { "settings": {
"title": "설정", "title": "설정",
"description": "앱 환경 설정 관리", "description": "앱 환경 설정 관리",
@@ -395,7 +506,7 @@
}, },
"other": { "other": {
"title": "기타 설정", "title": "기타 설정",
"description": "도구, 감정 시스템 등의 설정을 구성합니다" "description": "전역 슬랭 등 기본 옵션을 설정합니다"
}, },
"siliconFlow": { "siliconFlow": {
"title": "API 설정", "title": "API 설정",
@@ -450,9 +561,12 @@
"personality": { "personality": {
"personality": "여자 대학생이며 현재 2학년이고, Tieba 같은 커뮤니티를 자주 봅니다.", "personality": "여자 대학생이며 현재 2학년이고, Tieba 같은 커뮤니티를 자주 봅니다.",
"replyStyle": "답변은 담백하고 짧게 하며 중국어로 말하세요. 자신의 학과 배경을 일부러 강조하지 마세요. Tieba, Zhihu, Weibo의 답변 스타일을 참고할 수 있습니다.", "replyStyle": "답변은 담백하고 짧게 하며 중국어로 말하세요. 자신의 학과 배경을 일부러 강조하지 마세요. Tieba, Zhihu, Weibo의 답변 스타일을 참고할 수 있습니다.",
"interest": "기술, 게임, 애니메이션, 일상적인 주제에 관심이 있고, 너무 무겁거나 엄숙한 주제는 좋아하지 않습니다.", "multipleReplyStyles": {
"planStyle": "1. 사용 가능한 **모든** action 의 **각 동작** 이 현재 조건에 맞는지 검토하고, 대화 내용에 맞으면 사용하세요\n2. 같은 내용이 이미 실행되었다면 반복하지 마세요\n3. 발화 빈도를 조절하고 너무 자주 말하지 마세요\n4. 누군가 당신을 귀찮아하는 것 같다면 답장을 줄이세요\n5. 누군가 당신을 공격하거나 감정적으로 반응하면 적절하게 대응하세요", "plain": "말투는 담백하지만 약간 냉소적이고, 매우 짧고 구어체입니다. Tieba 와 Weibo 답변 스타일을 참고할 수 있습니다.",
"privatePlanStyle": "1. 사용 가능한 **모든** action 의 **각 동작** 이 현재 조건에 맞는지 검토하고, 대화 내용에 맞으면 사용하세요\n2. 같은 내용이 이미 실행되었다면 반복하지 마세요\n3. 이미 답한 문장에는 다시 답하지 마세요" "shortText": "1-2글자로 답장하기",
"shortSymbol": "1-2개의 기호로 답장하기",
"translation": "살짝 번역체로 말하되 길게 쓰지 않기"
}
}, },
"emoji": { "emoji": {
"filtrationPrompt": "공공질서와 미풍양속에 어긋나지 않음" "filtrationPrompt": "공공질서와 미풍양속에 어긋나지 않음"
@@ -506,26 +620,20 @@
"placeholder": "봇이 말하는 방식과 표현 습관을 설명해 주세요", "placeholder": "봇이 말하는 방식과 표현 습관을 설명해 주세요",
"description": "예: 답변은 담백하고 짧게, 중국어로 말하며 Tieba, Zhihu, Weibo 스타일을 참고한다" "description": "예: 답변은 담백하고 짧게, 중국어로 말하며 Tieba, Zhihu, Weibo 스타일을 참고한다"
}, },
"interest": { "multipleReplyStyle": {
"label": "관심사 *", "label": "대체 답변 스타일",
"placeholder": "봇이 관심을 가지는 주제를 설명해 주세요", "placeholder": "한 줄에 하나씩 대체 답변 스타일을 입력하세요",
"description": "어떤 주제에 더 잘 반응할지에 영향을 줍니다" "description": "목록이 비어 있지 않으면 MaiBot 이 아래 확률에 따라 기본 답변 스타일을 무작위로 대체합니다"
}, },
"planStyle": { "multipleProbability": {
"label": "그룹 채팅 규칙 *", "label": "대체 스타일 적용 확률",
"placeholder": "그룹 채팅에서의 행동 스타일과 규칙", "description": "답변을 만들 때마다 기본 답변 스타일을 대체 스타일로 무작위 교체할 확률입니다"
"description": "답장 빈도와 조건 등 그룹 채팅에서의 행동 방식을 정의합니다"
},
"privatePlanStyle": {
"label": "개인 채팅 규칙 *",
"placeholder": "개인 채팅에서의 행동 스타일과 규칙",
"description": "개인 채팅에서의 행동 방식을 정의합니다"
} }
}, },
"emoji": { "emoji": {
"emojiChance": { "emojiSendNum": {
"label": "이모지 활성화 확률", "label": "이모지 후보 수",
"description": "봇이 이모지를 보낼 확률입니다" "description": "전송 전에 몇 개의 이모지 중에서 고를지 정합니다. 최대 64개입니다"
}, },
"maxRegNum": { "maxRegNum": {
"label": "최대 이모지 수", "label": "최대 이모지 수",
@@ -554,10 +662,6 @@
} }
}, },
"other": { "other": {
"enableTool": {
"label": "도구 시스템 사용",
"description": "봇이 다양한 도구를 사용해 기능을 확장할 수 있게 합니다"
},
"allGlobal": { "allGlobal": {
"label": "전역 슬랭 모드 사용", "label": "전역 슬랭 모드 사용",
"description": "봇이 그룹 슬랭을 학습하고 사용할 수 있게 합니다" "description": "봇이 그룹 슬랭을 학습하고 사용할 수 있게 합니다"

View File

@@ -43,11 +43,122 @@
"settings": "系统设置" "settings": "系统设置"
} }
}, },
"workspace": {
"switcherLabel": "切换麦麦工作区",
"settings": "麦麦设置",
"chat": "麦麦聊天"
},
"chat": {
"defaultTab": "WebUI",
"botNameFallback": "麦麦",
"userFallback": "用户",
"userNameFallback": "WebUI用户",
"virtualGroupFallback": "WebUI虚拟群聊",
"status": {
"connected": "已连接",
"connecting": "连接中...",
"disconnected": "未连接"
},
"actions": {
"cancel": "取消",
"reconnect": "重新连接",
"save": "保存",
"send": "发送消息"
},
"identity": {
"current": "当前身份:",
"editName": "修改昵称",
"group": "群:{{group}}",
"namePlaceholder": "输入昵称",
"virtual": "虚拟身份:"
},
"input": {
"placeholder": "输入消息...",
"waiting": "等待连接..."
},
"message": {
"empty": "开始与 {{bot}} 对话吧!",
"emptyHint": "在下方输入框输入消息,按 Enter 发送",
"errorFallback": "发生错误",
"thinking": "思考中..."
},
"composer": {
"hint": "Enter 发送 · Shift + Enter 换行"
},
"sidebar": {
"closeConversation": "关闭 {{label}}",
"conversations": "聊天会话",
"emptyPreview": "暂无消息",
"identityHint": "本地聊天室身份",
"newVirtual": "新建虚拟身份对话",
"online": "在线",
"offline": "离线",
"subtitle": "{{count}} 个会话",
"title": "聊天",
"webuiBadge": "WebUI",
"virtualBadge": "虚拟",
"profileTitle": "我的身份",
"editName": "编辑昵称",
"saveName": "保存"
},
"dialog": {
"create": "创建对话",
"description": "选择一个麦麦已认识的用户,以该用户的身份与麦麦对话。麦麦将使用她对该用户的记忆和认知来回应。",
"groupName": "虚拟群名(可选)",
"groupNameHint": "麦麦会认为这是一个名为此名称的群聊",
"knownUserSuffix": " · 已认识",
"loading": "加载中...",
"noUsers": "没有找到用户",
"personCount": "({{count}} 人)",
"platform": "选择平台",
"platformPlaceholder": "选择平台",
"searchUser": "搜索用户名...",
"title": "新建虚拟身份对话",
"user": "选择用户"
},
"media": {
"audioUnsupported": "您的浏览器不支持音频播放",
"emoji": "表情包",
"face": "[表情:{{data}}]",
"file": "[文件: {{data}}]",
"forward": "[转发消息]",
"image": "图片",
"loadFailed": "[{{type}}加载失败]",
"music": "[音乐分享]",
"noCaptions": "无字幕",
"reply": "[回复消息]",
"unknown": "[{{type}}]",
"unknownMessage": "未知消息",
"videoUnsupported": "您的浏览器不支持视频播放"
},
"toast": {
"backendUnavailable": "无法连接到后端服务,请确保 MaiBot 已启动",
"backendUnavailableShort": "无法连接到后端服务",
"connectionFailed": "连接失败",
"currentSessionUnavailable": "当前聊天会话不可用,请稍后重试",
"error": "错误",
"incompleteConfig": "配置不完整",
"networkError": "网络错误",
"platformFailed": "获取平台失败",
"selectPlatformAndUser": "请选择平台和用户",
"sendFailed": "发送失败",
"serverError": "服务器返回错误: {{status}}",
"sessionUnavailable": "无法建立聊天会话,请稍后重试",
"virtualTabCreated": "虚拟身份标签页",
"virtualTabCreatedDesc": "已创建 {{label}} 的对话"
}
},
"layout": { "layout": {
"verifyingLogin": "正在验证登录状态...", "verifyingLogin": "正在验证登录状态...",
"logoTitle": "MaiBot WebUI", "logoTitle": "MaiBot WebUI",
"logoTitleShort": "M" "logoTitleShort": "M"
}, },
"httpWarning": {
"title": "安全提示:",
"message": "当前正在通过 HTTP 访问 WebUI。",
"description": "非本地 HTTP 连接可能暴露访问令牌和配置内容,建议改用 HTTPS 或受信任的内网连接。",
"dismiss": "关闭 HTTP 安全提示"
},
"settings": { "settings": {
"title": "系统设置", "title": "系统设置",
"description": "管理您的应用偏好设置", "description": "管理您的应用偏好设置",
@@ -395,7 +506,7 @@
}, },
"other": { "other": {
"title": "其他设置", "title": "其他设置",
"description": "工具、情绪系统等配置" "description": "配置全局黑话等基础选项"
}, },
"siliconFlow": { "siliconFlow": {
"title": "API配置", "title": "API配置",
@@ -450,9 +561,12 @@
"personality": { "personality": {
"personality": "是一个女大学生,现在在读大二,会刷贴吧。", "personality": "是一个女大学生,现在在读大二,会刷贴吧。",
"replyStyle": "请回复得平淡一些,简短一些,说中文,不要刻意突出自身学科背景。可以参考贴吧、知乎和微博的回复风格。", "replyStyle": "请回复得平淡一些,简短一些,说中文,不要刻意突出自身学科背景。可以参考贴吧、知乎和微博的回复风格。",
"interest": "对技术相关话题、游戏和动漫相关话题感兴趣,也对日常话题感兴趣,不喜欢太过沉重严肃的话题。", "multipleReplyStyles": {
"planStyle": "1.思考**所有**的可用的 action 中的**每个动作**是否符合当下条件,如果动作使用条件符合聊天内容就使用\n2.如果相同的内容已经被执行,请不要重复执行\n3.请控制你的发言频率,不要太过频繁地发言\n4.如果有人对你感到厌烦,请减少回复\n5.如果有人对你进行攻击,或者情绪激动,请你以合适的方法应对", "plain": "你的风格平淡但不失讽刺,很简短,很白话。可以参考贴吧、微博的回复风格。",
"privatePlanStyle": "1.思考**所有**的可用的 action 中的**每个动作**是否符合当下条件,如果动作使用条件符合聊天内容就使用\n2.如果相同的内容已经被执行,请不要重复执行\n3.某句话如果已经被回复过,不要重复回复" "shortText": "用1-2个字进行回复",
"shortSymbol": "用1-2个符号进行回复",
"translation": "带点翻译腔,但不要太长"
}
}, },
"emoji": { "emoji": {
"filtrationPrompt": "符合公序良俗" "filtrationPrompt": "符合公序良俗"
@@ -506,26 +620,20 @@
"placeholder": "描述机器人说话的表达风格、表达习惯", "placeholder": "描述机器人说话的表达风格、表达习惯",
"description": "例如:回复平淡一些,简短一些,说中文,参考贴吧、知乎和微博的回复风格" "description": "例如:回复平淡一些,简短一些,说中文,参考贴吧、知乎和微博的回复风格"
}, },
"interest": { "multipleReplyStyle": {
"label": "兴趣 *", "label": "备用表达风格",
"placeholder": "描述机器人感兴趣的话题", "placeholder": "每行输入一种备用表达风格",
"description": "会影响机器人对什么话题进行回复" "description": "当列表不为空时,麦麦会按概率从这些风格中随机替换默认表达风格"
}, },
"planStyle": { "multipleProbability": {
"label": "群聊说话规则 *", "label": "备用风格触发概率",
"placeholder": "机器人在群聊中的行为风格和规则", "description": "每次构建回复时,从备用表达风格中随机替换默认表达风格的概率"
"description": "定义机器人在群聊中如何行动,例如回复频率、条件等"
},
"privatePlanStyle": {
"label": "私聊说话规则 *",
"placeholder": "机器人在私聊中的行为风格和规则",
"description": "定义机器人在私聊中的行为方式"
} }
}, },
"emoji": { "emoji": {
"emojiChance": { "emojiSendNum": {
"label": "表情包激活概率", "label": "表情包候选数量",
"description": "机器人发送表情包的概率" "description": "每次发送前从多少个表情包中选择,最大为 64"
}, },
"maxRegNum": { "maxRegNum": {
"label": "最大表情包数量", "label": "最大表情包数量",
@@ -554,10 +662,6 @@
} }
}, },
"other": { "other": {
"enableTool": {
"label": "启用工具系统",
"description": "允许机器人使用各种工具增强功能"
},
"allGlobal": { "allGlobal": {
"label": "启用全局黑话模式", "label": "启用全局黑话模式",
"description": "允许机器人学习和使用群组黑话" "description": "允许机器人学习和使用群组黑话"

View File

@@ -1,31 +1,13 @@
import { createRootRoute, createRoute, createRouter, Outlet, redirect } from '@tanstack/react-router' import {
createRootRoute,
createRoute,
createRouter,
lazyRouteComponent,
Outlet,
redirect,
} from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools' import { TanStackRouterDevtools } from '@tanstack/router-devtools'
import { IndexPage } from './routes/index'
import { SettingsPage } from './routes/settings'
import { AuthPage } from './routes/auth'
import { SetupPage } from './routes/setup'
import { NotFoundPage } from './routes/404' import { NotFoundPage } from './routes/404'
import { BotConfigPage } from './routes/config/bot'
import { ModelProviderConfigPage } from './routes/config/modelProvider'
import { ModelConfigPage } from './routes/config/model'
import { AdapterConfigPage } from './routes/config/adapter'
import { EmojiManagementPage } from './routes/resource/emoji'
import { ExpressionManagementPage } from './routes/resource/expression'
import { JargonManagementPage } from './routes/resource/jargon'
import { PersonManagementPage } from './routes/person'
import { KnowledgeGraphPage } from './routes/resource/knowledge-graph'
import { KnowledgeBasePage } from './routes/resource/knowledge-base'
import { LogViewerPage } from './routes/logs'
import { PlannerMonitorPage } from './routes/monitor'
import { PluginsPage } from './routes/plugins'
import { ModelPresetsPage } from './routes/model-presets'
import { PluginConfigPage } from './routes/plugin-config'
import { PluginMirrorsPage } from './routes/plugin-mirrors'
import { PluginDetailPage } from './routes/plugin-detail'
import { ChatPage } from './routes/chat/index'
import { WebUIFeedbackSurveyPage, MaiBotFeedbackSurveyPage } from './routes/survey'
import PackMarketPage from './routes/config/pack-market'
import PackDetailPage from './routes/config/pack-detail'
import { Layout } from './components/layout' import { Layout } from './components/layout'
import { checkAuth } from './hooks/use-auth' import { checkAuth } from './hooks/use-auth'
import { RouteErrorBoundary } from './components/error-boundary' import { RouteErrorBoundary } from './components/error-boundary'
@@ -50,14 +32,14 @@ const rootRoute = createRootRoute({
const authRoute = createRoute({ const authRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/auth', path: '/auth',
component: AuthPage, component: lazyRouteComponent(() => import('./routes/auth'), 'AuthPage'),
}) })
// 首次配置路由(无 Layout // 首次配置路由(无 Layout
const setupRoute = createRoute({ const setupRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/setup', path: '/setup',
component: SetupPage, component: lazyRouteComponent(() => import('./routes/setup/index.tsx'), 'SetupPage'),
}) })
// 受保护的路由 Root带 Layout // 受保护的路由 Root带 Layout
@@ -76,168 +58,192 @@ const protectedRoute = createRoute({
const indexRoute = createRoute({ const indexRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/', path: '/',
component: IndexPage, component: lazyRouteComponent(() => import('./routes/index'), 'IndexPage'),
}) })
// 配置路由 - 麦麦主程序配置 // 配置路由 - 麦麦主程序配置
const botConfigRoute = createRoute({ const botConfigRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/config/bot', path: '/config/bot',
component: BotConfigPage, component: lazyRouteComponent(() => import('./routes/config/bot'), 'BotConfigPage'),
}) })
// 配置路由 - 麦麦模型提供商配置 // 配置路由 - 麦麦模型提供商配置
const modelProviderConfigRoute = createRoute({ const modelProviderConfigRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/config/modelProvider', path: '/config/modelProvider',
component: ModelProviderConfigPage, component: lazyRouteComponent(
() => import('./routes/config/modelProvider/index.tsx'),
'ModelProviderConfigPage'
),
}) })
// 配置路由 - 麦麦模型配置 // 配置路由 - 麦麦模型配置
const modelConfigRoute = createRoute({ const modelConfigRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/config/model', path: '/config/model',
component: ModelConfigPage, component: lazyRouteComponent(() => import('./routes/config/model'), 'ModelConfigPage'),
}) })
// 配置路由 - 麦麦适配器配置 // 配置路由 - 麦麦适配器配置
const adapterConfigRoute = createRoute({ const adapterConfigRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/config/adapter', path: '/config/adapter',
component: AdapterConfigPage, component: lazyRouteComponent(() => import('./routes/config/adapter'), 'AdapterConfigPage'),
}) })
// 资源管理路由 - 表情包管理 // 资源管理路由 - 表情包管理
const emojiManagementRoute = createRoute({ const emojiManagementRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/resource/emoji', path: '/resource/emoji',
component: EmojiManagementPage, component: lazyRouteComponent(
() => import('./routes/resource/emoji/index.tsx'),
'EmojiManagementPage'
),
}) })
// 资源管理路由 - 表达方式管理 // 资源管理路由 - 表达方式管理
const expressionManagementRoute = createRoute({ const expressionManagementRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/resource/expression', path: '/resource/expression',
component: ExpressionManagementPage, component: lazyRouteComponent(
() => import('./routes/resource/expression/index.tsx'),
'ExpressionManagementPage'
),
}) })
// 资源管理路由 - 人物信息管理 // 资源管理路由 - 人物信息管理
const personManagementRoute = createRoute({ const personManagementRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/resource/person', path: '/resource/person',
component: PersonManagementPage, component: lazyRouteComponent(() => import('./routes/person'), 'PersonManagementPage'),
}) })
// 资源管理路由 - 黑话管理 // 资源管理路由 - 黑话管理
const jargonManagementRoute = createRoute({ const jargonManagementRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/resource/jargon', path: '/resource/jargon',
component: JargonManagementPage, component: lazyRouteComponent(
() => import('./routes/resource/jargon/index.tsx'),
'JargonManagementPage'
),
}) })
// 资源管理路由 - 知识库图谱可视化 // 资源管理路由 - 知识库图谱可视化
const knowledgeGraphRoute = createRoute({ const knowledgeGraphRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/resource/knowledge-graph', path: '/resource/knowledge-graph',
component: KnowledgeGraphPage, component: lazyRouteComponent(
() => import('./routes/resource/knowledge-graph/index.tsx'),
'KnowledgeGraphPage'
),
}) })
// 资源管理路由 - 麦麦知识库管理 // 资源管理路由 - 麦麦知识库管理
const knowledgeBaseRoute = createRoute({ const knowledgeBaseRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/resource/knowledge-base', path: '/resource/knowledge-base',
component: KnowledgeBasePage, component: lazyRouteComponent(
() => import('./routes/resource/knowledge-base'),
'KnowledgeBasePage'
),
}) })
// 日志查看器路由 // 日志查看器路由
const logsRoute = createRoute({ const logsRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/logs', path: '/logs',
component: LogViewerPage, component: lazyRouteComponent(() => import('./routes/logs'), 'LogViewerPage'),
}) })
// MaiSaka 聊天流监控路由 // MaiSaka 聊天流监控路由
const plannerMonitorRoute = createRoute({ const plannerMonitorRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/planner-monitor', path: '/planner-monitor',
component: PlannerMonitorPage, component: lazyRouteComponent(() => import('./routes/monitor/index.tsx'), 'PlannerMonitorPage'),
}) })
// 本地聊天室路由 // 本地聊天室路由
const chatRoute = createRoute({ const chatRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/chat', path: '/chat',
component: ChatPage, component: lazyRouteComponent(() => import('./routes/chat/index'), 'ChatPage'),
}) })
// 插件市场路由 // 插件市场路由
const pluginsRoute = createRoute({ const pluginsRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/plugins', path: '/plugins',
component: PluginsPage, component: lazyRouteComponent(() => import('./routes/plugins/index'), 'PluginsPage'),
}) })
// 插件详情路由 // 插件详情路由
const pluginDetailRoute = createRoute({ const pluginDetailRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/plugin-detail', path: '/plugin-detail',
component: PluginDetailPage, component: lazyRouteComponent(() => import('./routes/plugin-detail'), 'PluginDetailPage'),
}) })
// 模型分配预设市场路由 // 模型分配预设市场路由
const modelPresetsRoute = createRoute({ const modelPresetsRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/model-presets', path: '/model-presets',
component: ModelPresetsPage, component: lazyRouteComponent(() => import('./routes/model-presets'), 'ModelPresetsPage'),
}) })
// 插件配置路由 // 插件配置路由
const pluginConfigRoute = createRoute({ const pluginConfigRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/plugin-config', path: '/plugin-config',
component: PluginConfigPage, component: lazyRouteComponent(() => import('./routes/plugin-config'), 'PluginConfigPage'),
}) })
// 插件镜像源配置路由 // 插件镜像源配置路由
const pluginMirrorsRoute = createRoute({ const pluginMirrorsRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/plugin-mirrors', path: '/plugin-mirrors',
component: PluginMirrorsPage, component: lazyRouteComponent(() => import('./routes/plugin-mirrors'), 'PluginMirrorsPage'),
}) })
// 设置页路由 // 设置页路由
const settingsRoute = createRoute({ const settingsRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/settings', path: '/settings',
component: SettingsPage, component: lazyRouteComponent(() => import('./routes/settings/index.tsx'), 'SettingsPage'),
}) })
// 配置模板市场路由 // 配置模板市场路由
const packMarketRoute = createRoute({ const packMarketRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/config/pack-market', path: '/config/pack-market',
component: PackMarketPage, component: lazyRouteComponent(() => import('./routes/config/pack-market')),
}) })
// 配置模板详情路由 // 配置模板详情路由
export const packDetailRoute = createRoute({ export const packDetailRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/config/pack-market/$packId', path: '/config/pack-market/$packId',
component: PackDetailPage, component: lazyRouteComponent(() => import('./routes/config/pack-detail')),
}) })
// 问卷调查路由 - WebUI 反馈 // 问卷调查路由 - WebUI 反馈
const webuiFeedbackSurveyRoute = createRoute({ const webuiFeedbackSurveyRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/survey/webui-feedback', path: '/survey/webui-feedback',
component: WebUIFeedbackSurveyPage, component: lazyRouteComponent(
() => import('./routes/survey/webui-feedback'),
'WebUIFeedbackSurveyPage'
),
}) })
// 问卷调查路由 - 麦麦体验反馈 // 问卷调查路由 - 麦麦体验反馈
const maibotFeedbackSurveyRoute = createRoute({ const maibotFeedbackSurveyRoute = createRoute({
getParentRoute: () => protectedRoute, getParentRoute: () => protectedRoute,
path: '/survey/maibot-feedback', path: '/survey/maibot-feedback',
component: MaiBotFeedbackSurveyPage, component: lazyRouteComponent(
() => import('./routes/survey/maibot-feedback'),
'MaiBotFeedbackSurveyPage'
),
}) })
// 404 路由 // 404 路由

View File

@@ -0,0 +1,72 @@
import { Send } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
interface ChatComposerProps {
value: string
onChange: (value: string) => void
onSend: () => void
disabled: boolean
isConnected: boolean
}
/**
* 聊天输入区:自适应高度的输入框 + 浮动发送按钮,带快捷键提示。
*/
export function ChatComposer({ value, onChange, onSend, disabled, isConnected }: ChatComposerProps) {
const { t } = useTranslation()
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault()
if (!disabled) onSend()
}
}
const canSend = !disabled && value.trim().length > 0
return (
<div className="bg-card/85 supports-backdrop-filter:bg-card/65 shrink-0 border-t backdrop-blur">
<div className="mx-auto max-w-4xl px-3 py-3 sm:px-6 sm:py-4">
<div
className={cn(
'group bg-background/80 focus-within:border-primary/60 focus-within:ring-primary/20 relative flex items-end gap-2 rounded-2xl border px-3 py-2 shadow-sm transition focus-within:ring-2',
!isConnected && 'opacity-70'
)}
>
<Textarea
aria-label={t('chat.input.placeholder')}
autoResize
className="max-h-40 min-h-9 flex-1 resize-none border-0 bg-transparent px-1 py-1.5 text-sm shadow-none focus-visible:ring-0"
disabled={!isConnected}
maxHeight={160}
minHeight={36}
placeholder={isConnected ? t('chat.input.placeholder') : t('chat.input.waiting')}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
/>
<Button
aria-label={t('chat.actions.send')}
className={cn(
'h-9 w-9 shrink-0 rounded-full transition',
canSend ? 'shadow-md' : 'opacity-60'
)}
disabled={!canSend}
size="icon"
title={t('chat.actions.send')}
onClick={onSend}
>
<Send className="h-4 w-4" />
</Button>
</div>
<p className="text-muted-foreground mt-1.5 hidden px-2 text-[11px] sm:block">
{t('chat.composer.hint')}
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { Bot, Loader2, RefreshCw, UserCircle2, Users, Wifi, WifiOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { ChatTab } from './types'
interface ChatHeaderBarProps {
activeTab: ChatTab | undefined
botDisplayName: string
isConnecting: boolean
isLoadingHistory: boolean
onReconnect: () => void
}
/**
* 聊天主面板顶部信息栏:展示当前会话头像、标题、连接状态以及操作按钮。
*/
export function ChatHeaderBar({
activeTab,
botDisplayName,
isConnecting,
isLoadingHistory,
onReconnect,
}: ChatHeaderBarProps) {
const { t } = useTranslation()
const isVirtual = activeTab?.type === 'virtual'
const virtualConfig = activeTab?.virtualConfig
const connected = activeTab?.isConnected ?? false
return (
<header className="bg-card/85 supports-backdrop-filter:bg-card/65 relative z-1 shrink-0 border-b backdrop-blur">
<div className="flex items-center justify-between gap-3 px-4 py-3 sm:px-6 sm:py-4">
<div className="flex min-w-0 items-center gap-3">
{/* 头像 + 在线状态指示点 */}
<div className="relative shrink-0">
<Avatar className="h-10 w-10 ring-1 ring-border/60 sm:h-11 sm:w-11">
<AvatarFallback className="bg-primary-gradient text-primary-foreground">
<Bot className="h-5 w-5" />
</AvatarFallback>
</Avatar>
<span
aria-hidden="true"
className={cn(
'absolute right-0 bottom-0 h-3 w-3 rounded-full border-2 border-card transition-colors',
connected ? 'bg-emerald-500' : isConnecting ? 'bg-amber-500' : 'bg-muted-foreground/60'
)}
/>
</div>
{/* 标题与副标题 */}
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold leading-tight sm:text-base">
{botDisplayName}
</h1>
<div className="text-muted-foreground mt-0.5 flex items-center gap-1.5 text-xs leading-tight">
{connected ? (
<>
<Wifi className="h-3 w-3 text-emerald-500" />
<span>{t('chat.status.connected')}</span>
</>
) : isConnecting ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
<span>{t('chat.status.connecting')}</span>
</>
) : (
<>
<WifiOff className="h-3 w-3 text-rose-500" />
<span>{t('chat.status.disconnected')}</span>
</>
)}
{isVirtual && virtualConfig && (
<>
<span aria-hidden className="text-muted-foreground/40">·</span>
<span className="inline-flex items-center gap-1">
<UserCircle2 className="h-3 w-3" />
<span className="max-w-40 truncate">{virtualConfig.userName}</span>
</span>
<span className="bg-muted text-muted-foreground rounded-full px-1.5 py-0.5 text-[10px] font-medium">
{virtualConfig.platform}
</span>
{virtualConfig.groupName && (
<span className="hidden items-center gap-1 sm:inline-flex">
<Users className="h-3 w-3" />
<span className="max-w-40 truncate">{virtualConfig.groupName}</span>
</span>
)}
</>
)}
</div>
</div>
</div>
{/* 右侧操作 */}
<div className="flex shrink-0 items-center gap-1">
{isLoadingHistory && (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" aria-hidden="true" />
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t('chat.actions.reconnect')}
className="h-9 w-9 rounded-full"
disabled={isConnecting}
size="icon"
variant="ghost"
onClick={onReconnect}
>
<RefreshCw className={cn('h-4 w-4', isConnecting && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('chat.actions.reconnect')}</TooltipContent>
</Tooltip>
</div>
</div>
</header>
)
}

View File

@@ -1,5 +1,7 @@
import { Bot, Plus, UserCircle2, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { MessageSquare, Plus, UserCircle2, X } from 'lucide-react'
import type { ChatTab } from './types' import type { ChatTab } from './types'
@@ -11,69 +13,74 @@ interface ChatTabBarProps {
onAddVirtual: () => void onAddVirtual: () => void
} }
export function ChatTabBar({ /**
tabs, * 移动端横向会话切换条:在窄屏隐藏侧边栏时使用,保持与桌面端一致的视觉语言。
activeTabId, */
onSwitch, export function ChatTabBar({ tabs, activeTabId, onSwitch, onClose, onAddVirtual }: ChatTabBarProps) {
onClose, const { t } = useTranslation()
onAddVirtual,
}: ChatTabBarProps) {
return ( return (
<div className="shrink-0 border-b bg-muted/30"> <div className="bg-card/85 supports-backdrop-filter:bg-card/65 shrink-0 border-b backdrop-blur">
<div className="max-w-4xl mx-auto px-2 sm:px-4"> <div className="scrollbar-thin flex items-center gap-1 overflow-x-auto px-3 py-2">
<div className="flex items-center gap-1 overflow-x-auto py-1.5 scrollbar-thin"> {tabs.map((tab) => {
{tabs.map((tab) => ( const active = activeTabId === tab.id
<button const Icon = tab.type === 'virtual' ? UserCircle2 : Bot
return (
<div
key={tab.id} key={tab.id}
className={cn( className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm whitespace-nowrap transition-colors cursor-pointer", 'group flex shrink-0 items-center rounded-full border text-xs transition',
"hover:bg-muted", active
activeTabId === tab.id ? 'bg-primary text-primary-foreground border-transparent shadow-sm'
? "bg-background shadow-sm border" : 'bg-background/60 text-muted-foreground hover:text-foreground hover:bg-background border-transparent'
: "text-muted-foreground"
)} )}
type="button"
onClick={() => onSwitch(tab.id)}
> >
{tab.type === 'webui' ? ( <button
<MessageSquare className="h-3.5 w-3.5" /> type="button"
) : ( className="flex items-center gap-1.5 rounded-full px-3 py-1.5"
<UserCircle2 className="h-3.5 w-3.5" /> onClick={() => onSwitch(tab.id)}
)} >
<span className="max-w-[100px] truncate">{tab.label}</span> <Icon className="h-3.5 w-3.5" />
{/* 连接状态指示器 */} <span className="max-w-32 truncate font-medium">{tab.label}</span>
<span className={cn(
"w-1.5 h-1.5 rounded-full",
tab.isConnected ? "bg-green-500" : "bg-muted-foreground/50"
)} />
{/* 关闭按钮(非默认标签页) */}
{tab.id !== 'webui-default' && (
<span <span
aria-hidden
className={cn(
'h-1.5 w-1.5 rounded-full transition-colors',
active
? tab.isConnected
? 'bg-primary-foreground'
: 'bg-primary-foreground/50'
: tab.isConnected
? 'bg-emerald-500'
: 'bg-muted-foreground/40'
)}
/>
</button>
{tab.id !== 'webui-default' && (
<button
type="button"
aria-label={t('chat.sidebar.closeConversation', { label: tab.label })}
className={cn(
'mr-1 rounded-full p-0.5 transition',
active ? 'hover:bg-primary-foreground/20' : 'hover:bg-muted'
)}
onClick={(e) => onClose(tab.id, e)} onClick={(e) => onClose(tab.id, e)}
className="ml-0.5 p-0.5 rounded hover:bg-muted-foreground/20 cursor-pointer"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClose(tab.id, e)
}
}}
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</span> </button>
)} )}
</button> </div>
))} )
{/* 新建虚拟身份标签页按钮 */} })}
<button <button
onClick={onAddVirtual} type="button"
className="flex items-center gap-1 px-2 py-1.5 rounded-md text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" aria-label={t('chat.sidebar.newVirtual')}
title="新建虚拟身份对话" title={t('chat.sidebar.newVirtual')}
> className="text-muted-foreground hover:bg-muted hover:text-foreground flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-dashed transition"
<Plus className="h-3.5 w-3.5" /> onClick={onAddVirtual}
</button> >
</div> <Plus className="h-3.5 w-3.5" />
</button>
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,265 @@
import { Bot, Check, Edit2, Plus, UserCircle2, X } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { ChatMessage, ChatTab } from './types'
interface ChatWorkspaceSidebarProps {
className?: string
tabs: ChatTab[]
activeTabId: string
userName: string
onSwitch: (tabId: string) => void
onClose: (tabId: string, e?: React.MouseEvent | React.KeyboardEvent) => void
onAddVirtual: () => void
onUpdateUserName: (name: string) => void
}
function getMessagePreview(message: ChatMessage | undefined, fallback: string, thinking: string) {
if (!message) return fallback
if (message.type === 'thinking') return thinking
if (message.type === 'system' || message.type === 'error') return message.content || fallback
return message.content || fallback
}
function ConversationItem({
tab,
active,
onSwitch,
onClose,
}: {
tab: ChatTab
active: boolean
onSwitch: (id: string) => void
onClose: (id: string, e?: React.MouseEvent | React.KeyboardEvent) => void
}) {
const { t } = useTranslation()
const isVirtual = tab.type === 'virtual'
const lastMessage = tab.messages[tab.messages.length - 1]
const preview = getMessagePreview(
lastMessage,
t('chat.sidebar.emptyPreview'),
t('chat.message.thinking')
)
const Icon = isVirtual ? UserCircle2 : Bot
return (
<div
className={cn(
'group relative flex w-full min-w-0 items-center gap-1 rounded-xl pr-1 transition-colors',
active
? 'bg-primary/12 text-foreground shadow-inner'
: 'hover:bg-muted/70 text-foreground/90'
)}
>
{active && (
<span aria-hidden className="bg-primary absolute top-2 bottom-2 left-0 w-1 rounded-full" />
)}
<button
type="button"
className="flex w-full min-w-0 flex-1 items-center gap-3 overflow-hidden rounded-xl px-2.5 py-2 text-left"
onClick={() => onSwitch(tab.id)}
>
<div className="relative shrink-0">
<Avatar className="h-11 w-11 ring-1 ring-border/60">
<AvatarFallback
className={cn(
'text-xs',
isVirtual
? 'bg-secondary text-secondary-foreground'
: 'bg-primary-gradient text-primary-foreground'
)}
>
<Icon className="h-5 w-5" />
</AvatarFallback>
</Avatar>
<span
aria-hidden
className={cn(
'border-card absolute right-0 bottom-0 h-3 w-3 rounded-full border-2 transition-colors',
tab.isConnected ? 'bg-emerald-500' : 'bg-muted-foreground/40'
)}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-medium">{tab.label}</span>
<span
className={cn(
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium tracking-wide',
isVirtual
? 'bg-secondary text-secondary-foreground'
: 'bg-primary/15 text-primary'
)}
>
{isVirtual ? t('chat.sidebar.virtualBadge') : t('chat.sidebar.webuiBadge')}
</span>
</div>
<p className="text-muted-foreground mt-0.5 truncate text-xs">{preview}</p>
</div>
</button>
{tab.id !== 'webui-default' && (
<button
type="button"
aria-label={t('chat.sidebar.closeConversation', { label: tab.label })}
className="text-muted-foreground hover:bg-background hover:text-foreground rounded-md p-1 opacity-0 transition group-hover:opacity-100 focus-visible:opacity-100"
onClick={(e) => onClose(tab.id, e)}
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
)
}
export function ChatWorkspaceSidebar({
className,
tabs,
activeTabId,
userName,
onSwitch,
onClose,
onAddVirtual,
onUpdateUserName,
}: ChatWorkspaceSidebarProps) {
const { t } = useTranslation()
const [editing, setEditing] = useState(false)
const [draftName, setDraftName] = useState(userName)
const startEditing = () => {
setDraftName(userName)
setEditing(true)
}
const commit = () => {
const next = draftName.trim() || t('chat.userNameFallback')
onUpdateUserName(next)
setEditing(false)
}
return (
<aside
className={cn(
'bg-card/90 supports-backdrop-filter:bg-card/70 flex h-full w-72 shrink-0 flex-col border-r backdrop-blur xl:w-80',
className
)}
>
{/* 头部:标题 + 新建按钮 */}
<div className="border-b px-4 pt-5 pb-4">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate text-lg font-semibold tracking-tight">
{t('chat.sidebar.title')}
</h2>
<p className="text-muted-foreground mt-0.5 truncate text-xs">
{t('chat.sidebar.subtitle', { count: tabs.length })}
</p>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t('chat.sidebar.newVirtual')}
className="h-9 w-9 shrink-0 rounded-full shadow-sm"
size="icon"
onClick={onAddVirtual}
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('chat.sidebar.newVirtual')}</TooltipContent>
</Tooltip>
</div>
</div>
{/* 会话列表 */}
<ScrollArea
className="min-h-0 flex-1"
contentClassName="!block w-full min-w-0"
scrollbars="vertical"
viewportClassName="[&>div]:!block [&>div]:!min-w-0 [&>div]:w-full"
>
<nav aria-label={t('chat.sidebar.conversations')} className="space-y-0.5 p-2">
{tabs.map((tab) => (
<ConversationItem
key={tab.id}
active={activeTabId === tab.id}
tab={tab}
onSwitch={onSwitch}
onClose={onClose}
/>
))}
</nav>
</ScrollArea>
{/* 底部:本地用户身份 */}
<div className="border-t p-3">
<div className="bg-background/70 hover:bg-background flex items-center gap-3 rounded-xl border p-2.5 transition-colors">
<Avatar className="h-10 w-10 shrink-0 ring-1 ring-border/60">
<AvatarFallback className="bg-secondary text-secondary-foreground">
<UserCircle2 className="h-5 w-5" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t('chat.sidebar.profileTitle')}
</p>
{editing ? (
<div className="mt-0.5 flex items-center gap-1">
<Input
autoFocus
className="h-7 text-sm"
placeholder={t('chat.identity.namePlaceholder')}
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault()
commit()
} else if (e.key === 'Escape') {
setEditing(false)
}
}}
/>
<Button
aria-label={t('chat.sidebar.saveName')}
className="h-7 w-7 shrink-0"
size="icon"
variant="ghost"
onClick={commit}
>
<Check className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<div className="flex min-w-0 items-center gap-1">
<p className="min-w-0 flex-1 truncate text-sm font-medium">{userName}</p>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t('chat.sidebar.editName')}
className="h-6 w-6 shrink-0 opacity-60 hover:opacity-100"
size="icon"
variant="ghost"
onClick={startEditing}
>
<Edit2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{t('chat.sidebar.editName')}</TooltipContent>
</Tooltip>
</div>
)}
</div>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,227 @@
import { Bot, Sparkles, User } from 'lucide-react'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
import { RenderMessageContent } from './MessageRenderer'
import type { ChatMessage } from './types'
interface MessageListProps {
messages: ChatMessage[]
isLoadingHistory: boolean
botDisplayName: string
userName: string
language: string
}
interface BubbleAvatarProps {
type: 'user' | 'bot' | 'thinking'
visible: boolean
}
function BubbleAvatar({ type, visible }: BubbleAvatarProps) {
return (
<div className="h-8 w-8 shrink-0 sm:h-9 sm:w-9">
{visible && (
<Avatar className="h-full w-full ring-1 ring-border/60">
<AvatarFallback
className={cn(
'text-xs',
type === 'user'
? 'bg-secondary text-secondary-foreground'
: 'bg-primary-gradient text-primary-foreground'
)}
>
{type === 'user' ? (
<User className="h-4 w-4" />
) : (
<Bot className="h-4 w-4" />
)}
</AvatarFallback>
</Avatar>
)}
</div>
)
}
function ThinkingBubble() {
const { t } = useTranslation()
return (
<div className="bg-muted/80 text-muted-foreground inline-flex items-center gap-2 rounded-2xl rounded-bl-sm px-3.5 py-2.5">
<span className="flex gap-1">
<span className="bg-primary/60 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:0ms]" />
<span className="bg-primary/60 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:150ms]" />
<span className="bg-primary/60 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:300ms]" />
</span>
<span className="text-xs">{t('chat.message.thinking')}</span>
</div>
)
}
function EmptyState({ botName }: { botName: string }) {
const { t } = useTranslation()
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 py-16 text-center">
<div className="bg-primary-gradient text-primary-foreground relative flex h-16 w-16 items-center justify-center rounded-2xl shadow-lg">
<Sparkles className="h-7 w-7" />
<span className="bg-primary/30 absolute inset-0 -z-10 animate-pulse rounded-2xl blur-xl" />
</div>
<div className="space-y-1">
<h2 className="text-base font-semibold sm:text-lg">
{t('chat.message.empty', { bot: botName })}
</h2>
<p className="text-muted-foreground text-xs sm:text-sm">{t('chat.message.emptyHint')}</p>
</div>
</div>
)
}
/**
* 聊天消息列表:支持连续同发送者消息分组、思考占位、富文本与系统/错误信息样式。
*/
export function MessageList({
messages,
isLoadingHistory,
botDisplayName,
userName,
language,
}: MessageListProps) {
const { t } = useTranslation()
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const formatTime = (timestamp: number) => {
const date = new Date(timestamp * 1000)
return date.toLocaleTimeString(language || 'zh-CN', {
hour: '2-digit',
minute: '2-digit',
})
}
if (messages.length === 0 && !isLoadingHistory) {
return (
<div className="min-w-0 min-h-0 flex-1 overflow-hidden">
<ScrollArea
className="h-full w-full"
contentClassName="!block w-full min-w-0"
scrollbars="vertical"
viewportClassName="[&>div]:!block [&>div]:!min-w-0 [&>div]:w-full"
>
<EmptyState botName={botDisplayName} />
</ScrollArea>
</div>
)
}
return (
<div className="min-w-0 min-h-0 flex-1 overflow-hidden">
<ScrollArea
className="h-full w-full"
contentClassName="!block w-full min-w-0"
scrollbars="vertical"
viewportClassName="[&>div]:!block [&>div]:!min-w-0 [&>div]:w-full"
>
<div className="mx-auto flex w-full max-w-4xl min-w-0 flex-col gap-1 px-3 py-5 sm:px-6 sm:py-6">
{messages.map((message, index) => {
// 系统消息:作为分隔条
if (message.type === 'system') {
return (
<div key={message.id} className="my-2 flex items-center gap-3">
<div className="bg-border/60 h-px flex-1" />
<span className="text-muted-foreground bg-card/70 rounded-full border px-3 py-0.5 text-[11px]">
{message.content}
</span>
<div className="bg-border/60 h-px flex-1" />
</div>
)
}
// 错误消息
if (message.type === 'error') {
return (
<div key={message.id} className="my-2 flex justify-center">
<div className="bg-destructive/10 text-destructive border-destructive/30 rounded-full border px-3 py-1 text-xs">
{message.content}
</div>
</div>
)
}
const isUser = message.type === 'user'
const isThinking = message.type === 'thinking'
const bubbleType: 'user' | 'bot' | 'thinking' = isUser ? 'user' : isThinking ? 'thinking' : 'bot'
// 是否与上一条消息属于同一发送者(用于分组:仅首条显示头像 + 名字)
const previous = messages[index - 1]
const sameGroup =
previous &&
previous.type === message.type &&
(previous.sender?.user_id ?? previous.sender?.name) ===
(message.sender?.user_id ?? message.sender?.name)
const senderName =
message.sender?.name || (isUser ? userName : botDisplayName)
return (
<div
key={message.id}
className={cn(
'flex w-full min-w-0 items-end gap-2 sm:gap-3',
isUser ? 'flex-row-reverse' : 'flex-row',
sameGroup ? 'mt-0.5' : 'mt-3 first:mt-0'
)}
>
<BubbleAvatar type={bubbleType === 'thinking' ? 'bot' : bubbleType} visible={!sameGroup} />
<div
className={cn(
'flex min-w-0 max-w-[80%] flex-col sm:max-w-[70%]',
isUser ? 'items-end' : 'items-start'
)}
>
{!sameGroup && (
<div
className={cn(
'text-muted-foreground mb-1 flex items-center gap-2 px-1 text-[11px]',
isUser && 'flex-row-reverse'
)}
>
<span className="hidden font-medium sm:inline">{senderName}</span>
<span>{formatTime(message.timestamp)}</span>
</div>
)}
{isThinking ? (
<ThinkingBubble />
) : (
<div
className={cn(
'shadow-sm/30 wrap-break-word min-w-0 max-w-full overflow-hidden px-3.5 py-2 text-sm leading-relaxed',
isUser
? 'bg-primary text-primary-foreground rounded-2xl rounded-br-md'
: 'bg-muted text-foreground rounded-2xl rounded-bl-md'
)}
>
<RenderMessageContent message={message} isBot={!isUser} />
</div>
)}
</div>
</div>
)
})}
<div ref={endRef} />
{/* 用于读屏 / 避免悬空 */}
<span className="sr-only" aria-live="polite">
{messages.length > 0 ? t('chat.sidebar.subtitle', { count: messages.length }) : ''}
</span>
</div>
</ScrollArea>
</div>
)
}

View File

@@ -1,87 +1,106 @@
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { ChatMessage, MessageSegment } from './types' import type { ChatMessage, MessageSegment } from './types'
// 渲染单个消息段 // 渲染单个消息段
export function RenderMessageSegment({ segment }: { segment: MessageSegment }) { export function RenderMessageSegment({ segment }: { segment: MessageSegment }) {
const { t } = useTranslation()
switch (segment.type) { switch (segment.type) {
case 'text': case 'text':
return <span className="whitespace-pre-wrap">{String(segment.data)}</span> return <span className="whitespace-pre-wrap">{String(segment.data)}</span>
case 'image': case 'image':
case 'emoji': case 'emoji': {
const mediaLabel = segment.type === 'emoji' ? t('chat.media.emoji') : t('chat.media.image')
return ( return (
<img <img
src={String(segment.data)} src={String(segment.data)}
alt={segment.type === 'emoji' ? '表情包' : '图片'} alt={mediaLabel}
className={cn( className={cn(
"rounded-lg max-w-full", 'max-w-full rounded-lg',
segment.type === 'emoji' ? "max-h-32" : "max-h-64" segment.type === 'emoji' ? 'max-h-32' : 'max-h-64'
)} )}
loading="lazy" loading="lazy"
onError={(e) => { onError={(e) => {
// 图片加载失败时显示占位符 // 图片加载失败时显示占位符
const target = e.target as HTMLImageElement const target = e.target as HTMLImageElement
target.style.display = 'none' target.style.display = 'none'
target.parentElement?.insertAdjacentHTML( const fallback = document.createElement('span')
'beforeend', fallback.className = 'text-muted-foreground text-xs'
`<span class="text-muted-foreground text-xs">[${segment.type === 'emoji' ? '表情包' : '图片'}加载失败]</span>` fallback.textContent = t('chat.media.loadFailed', { type: mediaLabel })
) target.parentElement?.appendChild(fallback)
}} }}
/> />
) )
}
case 'voice': case 'voice':
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<audio <audio controls src={String(segment.data)} className="h-8 max-w-[200px]">
controls <track kind="captions" src="" label={t('chat.media.noCaptions')} default />
src={String(segment.data)} {t('chat.media.audioUnsupported')}
className="max-w-[200px] h-8"
>
<track kind="captions" src="" label="无字幕" default />
</audio> </audio>
</div> </div>
) )
case 'video': case 'video':
return ( return (
<video <video controls src={String(segment.data)} className="max-h-64 max-w-full rounded-lg">
controls <track kind="captions" src="" label={t('chat.media.noCaptions')} default />
src={String(segment.data)} {t('chat.media.videoUnsupported')}
className="rounded-lg max-w-full max-h-64"
>
<track kind="captions" src="" label="无字幕" default />
</video> </video>
) )
case 'face': case 'face':
// QQ 原生表情,显示为文本 // QQ 原生表情,显示为文本
return <span className="text-muted-foreground">[:{String(segment.data)}]</span> return (
<span className="text-muted-foreground">
{t('chat.media.face', { data: String(segment.data) })}
</span>
)
case 'music': case 'music':
return <span className="text-muted-foreground">[]</span> return <span className="text-muted-foreground">{t('chat.media.music')}</span>
case 'file': case 'file':
return <span className="text-muted-foreground">[: {String(segment.data)}]</span> return (
<span className="text-muted-foreground">
{t('chat.media.file', { data: String(segment.data) })}
</span>
)
case 'reply': case 'reply':
return <span className="text-muted-foreground text-xs">[]</span> return <span className="text-muted-foreground text-xs">{t('chat.media.reply')}</span>
case 'forward': case 'forward':
return <span className="text-muted-foreground">[]</span> return <span className="text-muted-foreground">{t('chat.media.forward')}</span>
case 'unknown': case 'unknown':
default: default:
return <span className="text-muted-foreground">[{segment.original_type || '未知消息'}]</span> return (
<span className="text-muted-foreground">
{t('chat.media.unknown', {
type: segment.original_type || t('chat.media.unknownMessage'),
})}
</span>
)
} }
} }
// 渲染消息内容(支持富文本) // 渲染消息内容(支持富文本)
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
export function RenderMessageContent({ message, isBot: _isBot }: { message: ChatMessage; isBot: boolean }) { export function RenderMessageContent({
message,
isBot: _isBot,
}: {
message: ChatMessage
isBot: boolean
}) {
// 如果是富文本消息,渲染消息段 // 如果是富文本消息,渲染消息段
if (message.message_type === 'rich' && message.segments && message.segments.length > 0) { if (message.message_type === 'rich' && message.segments && message.segments.length > 0) {
return ( return (

View File

@@ -8,9 +8,9 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from "@/components/ui/label" import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { import {
Select, Select,
@@ -18,9 +18,10 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from '@/components/ui/select'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Globe, Loader2, Search, UserCircle2, Users } from 'lucide-react' import { Globe, Loader2, Search, UserCircle2, Users } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { PersonInfo, PlatformInfo, VirtualIdentityConfig } from './types' import type { PersonInfo, PlatformInfo, VirtualIdentityConfig } from './types'
@@ -53,30 +54,33 @@ export function VirtualIdentityDialog({
onSelectPerson, onSelectPerson,
onCreateVirtualTab, onCreateVirtualTab,
}: VirtualIdentityDialogProps) { }: VirtualIdentityDialogProps) {
const { t } = useTranslation()
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-125 max-h-[85vh] overflow-hidden flex flex-col" confirmOnEnter> <DialogContent
className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-125"
confirmOnEnter
>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<UserCircle2 className="h-5 w-5" /> <UserCircle2 className="h-5 w-5" />
{t('chat.dialog.title')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>{t('chat.dialog.description')}</DialogDescription>
,使
</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogBody className="space-y-4 flex-1" viewportClassName="pr-0"> <DialogBody className="flex-1 space-y-4" viewportClassName="pr-0">
{/* 平台选择 */} {/* 平台选择 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="flex items-center gap-2"> <Label className="flex items-center gap-2">
<Globe className="h-4 w-4" /> <Globe className="h-4 w-4" />
{t('chat.dialog.platform')}
</Label> </Label>
<Select <Select
value={tempVirtualConfig.platform} value={tempVirtualConfig.platform}
onValueChange={(value) => { onValueChange={(value) => {
setTempVirtualConfig(prev => ({ setTempVirtualConfig((prev) => ({
...prev, ...prev,
platform: value, platform: value,
personId: '', personId: '',
@@ -86,12 +90,18 @@ export function VirtualIdentityDialog({
}} }}
> >
<SelectTrigger disabled={isLoadingPlatforms}> <SelectTrigger disabled={isLoadingPlatforms}>
<SelectValue placeholder={isLoadingPlatforms ? "加载中..." : "选择平台"} /> <SelectValue
placeholder={
isLoadingPlatforms
? t('chat.dialog.loading')
: t('chat.dialog.platformPlaceholder')
}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{platforms.map((p) => ( {platforms.map((p) => (
<SelectItem key={p.platform} value={p.platform}> <SelectItem key={p.platform} value={p.platform}>
{p.platform} ({p.count} ) {p.platform} {t('chat.dialog.personCount', { count: p.count })}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -100,70 +110,76 @@ export function VirtualIdentityDialog({
{/* 用户搜索和选择 */} {/* 用户搜索和选择 */}
{tempVirtualConfig.platform && ( {tempVirtualConfig.platform && (
<div className="space-y-2 flex-1 overflow-hidden flex flex-col"> <div className="flex flex-1 flex-col space-y-2 overflow-hidden">
<Label className="flex items-center gap-2"> <Label className="flex items-center gap-2">
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
{t('chat.dialog.user')}
</Label> </Label>
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
placeholder="搜索用户名..." placeholder={t('chat.dialog.searchUser')}
value={personSearchQuery} value={personSearchQuery}
onChange={(e) => setPersonSearchQuery(e.target.value)} onChange={(e) => setPersonSearchQuery(e.target.value)}
className="pl-9" className="pl-9"
/> />
</div> </div>
<ScrollArea className="h-62.5 border rounded-md"> <ScrollArea className="bg-background/40 h-62.5 rounded-lg border">
<div className="p-2"> <div className="p-1.5">
{isLoadingPersons ? ( {isLoadingPersons ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-10">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div> </div>
) : persons.length === 0 ? ( ) : persons.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground"> <div className="text-muted-foreground flex flex-col items-center justify-center py-10">
<Users className="h-8 w-8 mb-2 opacity-50" /> <Users className="mb-2 h-8 w-8 opacity-50" />
<p className="text-sm"></p> <p className="text-sm">{t('chat.dialog.noUsers')}</p>
</div> </div>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-0.5">
{persons.map((person) => ( {persons.map((person) => {
<button const selected = tempVirtualConfig.personId === person.person_id
key={person.person_id} const display = person.nickname || person.person_name
onClick={() => onSelectPerson(person)} return (
className={cn( <button
"w-full flex items-center gap-3 p-2 rounded-md text-left transition-colors", key={person.person_id}
tempVirtualConfig.personId === person.person_id type="button"
? "bg-primary text-primary-foreground" onClick={() => onSelectPerson(person)}
: "hover:bg-muted" className={cn(
)} 'flex w-full items-center gap-3 rounded-md p-2 text-left transition-colors',
> selected
<Avatar className="h-8 w-8 shrink-0"> ? 'bg-primary/12 text-foreground'
<AvatarFallback className={cn( : 'hover:bg-muted/70'
"text-xs", )}
tempVirtualConfig.personId === person.person_id >
? "bg-primary-foreground/20" <Avatar className="h-9 w-9 shrink-0 ring-1 ring-border/60">
: "bg-muted" <AvatarFallback
)}> className={cn(
{(person.nickname || person.person_name || '?').charAt(0)} 'text-xs font-semibold',
</AvatarFallback> selected
</Avatar> ? 'bg-primary-gradient text-primary-foreground'
<div className="min-w-0 flex-1"> : 'bg-muted text-foreground'
<div className="font-medium truncate"> )}
{person.nickname || person.person_name} >
{(display || '?').charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{display}</span>
{person.is_known && (
<span className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 rounded-full px-1.5 py-0.5 text-[10px] font-medium">
{t('chat.dialog.knownUserSuffix').replace(/^\s*·\s*/, '')}
</span>
)}
</div>
<div className="text-muted-foreground truncate font-mono text-[11px]">
{person.user_id}
</div>
</div> </div>
<div className={cn( </button>
"text-xs truncate", )
tempVirtualConfig.personId === person.person_id })}
? "text-primary-foreground/70"
: "text-muted-foreground"
)}>
ID: {person.user_id}
{person.is_known && " · 已认识"}
</div>
</div>
</button>
))}
</div> </div>
)} )}
</div> </div>
@@ -174,32 +190,32 @@ export function VirtualIdentityDialog({
{/* 虚拟群名配置 */} {/* 虚拟群名配置 */}
{tempVirtualConfig.personId && ( {tempVirtualConfig.personId && (
<div className="space-y-2"> <div className="space-y-2">
<Label></Label> <Label>{t('chat.dialog.groupName')}</Label>
<Input <Input
placeholder="WebUI虚拟群聊" placeholder={t('chat.virtualGroupFallback')}
value={tempVirtualConfig.groupName} value={tempVirtualConfig.groupName}
onChange={(e) => setTempVirtualConfig(prev => ({ onChange={(e) =>
...prev, setTempVirtualConfig((prev) => ({
groupName: e.target.value ...prev,
}))} groupName: e.target.value,
}))
}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs">{t('chat.dialog.groupNameHint')}</p>
</p>
</div> </div>
)} )}
</DialogBody> </DialogBody>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
{t('chat.actions.cancel')}
</Button> </Button>
<Button <Button
data-dialog-action="confirm" data-dialog-action="confirm"
onClick={onCreateVirtualTab} onClick={onCreateVirtualTab}
disabled={!tempVirtualConfig.platform || !tempVirtualConfig.personId} disabled={!tempVirtualConfig.platform || !tempVirtualConfig.personId}
> >
{t('chat.dialog.create')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { CodeEditor } from '@/components' import { CodeEditor } from '@/components/CodeEditor'
import { DynamicConfigForm } from '@/components/dynamic-form' import { DynamicConfigForm } from '@/components/dynamic-form'
import { RestartOverlay } from '@/components/restart-overlay' import { RestartOverlay } from '@/components/restart-overlay'
import { useToast } from '@/hooks/use-toast' import { useToast } from '@/hooks/use-toast'

View File

@@ -12,7 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { ListFieldEditor } from '@/components/ListFieldEditor' import { ListFieldEditor } from '@/components/ListFieldEditor'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { CodeEditor } from '@/components' import { CodeEditor } from '@/components/CodeEditor'
import { parse as parseToml } from 'smol-toml' import { parse as parseToml } from 'smol-toml'
import { import {
Select, Select,

View File

@@ -37,8 +37,8 @@ import {
type GitStatus, type GitStatus,
type MaimaiVersion, type MaimaiVersion,
} from '@/lib/plugin-api' } from '@/lib/plugin-api'
import { MarkdownRenderer } from '@/components/markdown-renderer'
import { PluginStats } from '@/components/plugin-stats' import { PluginStats } from '@/components/plugin-stats'
import { MarkdownRenderer } from '@/components'
import { recordPluginDownload } from '@/lib/plugin-stats' import { recordPluginDownload } from '@/lib/plugin-stats'
// 分类名称映射 // 分类名称映射

View File

@@ -16,7 +16,7 @@ import {
Upload, Upload,
} from 'lucide-react' } from 'lucide-react'
import { CodeEditor } from '@/components' import { CodeEditor } from '@/components/CodeEditor'
import { MemoryDeleteDialog } from '@/components/memory/MemoryDeleteDialog' import { MemoryDeleteDialog } from '@/components/memory/MemoryDeleteDialog'
import { MemoryConfigEditor } from '@/components/memory/MemoryConfigEditor' import { MemoryConfigEditor } from '@/components/memory/MemoryConfigEditor'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'

View File

@@ -171,10 +171,14 @@ export function NodeDetailDialog({
</Button> </Button>
{onDeleteEntity ? ( {onDeleteEntity ? (
<div className="flex flex-col items-end gap-2 rounded-lg border bg-background p-3"> <div className="flex flex-col items-end gap-2 rounded-lg border bg-background p-3">
<label className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Checkbox checked={includeParagraphs} onCheckedChange={(checked) => setIncludeParagraphs(Boolean(checked))} /> <Checkbox
checked={includeParagraphs}
</label> onCheckedChange={(checked) => setIncludeParagraphs(Boolean(checked))}
aria-label="删除该实体相关证据段落"
/>
<span></span>
</div>
<Button variant="outline" onClick={() => onDeleteEntity({ includeParagraphs })}> <Button variant="outline" onClick={() => onDeleteEntity({ includeParagraphs })}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
@@ -280,10 +284,14 @@ export function EdgeDetailDialog({
</Button> </Button>
{onDeleteEdgeGroup ? ( {onDeleteEdgeGroup ? (
<div className="flex flex-col items-end gap-2 rounded-lg border bg-background p-3"> <div className="flex flex-col items-end gap-2 rounded-lg border bg-background p-3">
<label className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Checkbox checked={includeParagraphs} onCheckedChange={(checked) => setIncludeParagraphs(Boolean(checked))} /> <Checkbox
checked={includeParagraphs}
</label> onCheckedChange={(checked) => setIncludeParagraphs(Boolean(checked))}
aria-label="同时删除支撑段落"
/>
<span></span>
</div>
<Button variant="outline" onClick={() => onDeleteEdgeGroup({ includeParagraphs })}> <Button variant="outline" onClick={() => onDeleteEdgeGroup({ includeParagraphs })}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
@@ -371,10 +379,14 @@ export function RelationDetailDialog({
{onDeleteRelation ? ( {onDeleteRelation ? (
<div className="rounded-lg border bg-background p-3"> <div className="rounded-lg border bg-background p-3">
<label className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Checkbox checked={includeParagraphs} onCheckedChange={(checked) => setIncludeParagraphs(Boolean(checked))} /> <Checkbox
checked={includeParagraphs}
</label> onCheckedChange={(checked) => setIncludeParagraphs(Boolean(checked))}
aria-label="同时删除支撑该关系的段落"
/>
<span></span>
</div>
<Button className="mt-3" variant="outline" onClick={() => onDeleteRelation(relation, includeParagraphs)}> <Button className="mt-3" variant="outline" onClick={() => onDeleteRelation(relation, includeParagraphs)}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />

View File

@@ -313,6 +313,7 @@ interface PersonalityFormProps {
export function PersonalityForm({ config, onChange }: PersonalityFormProps) { export function PersonalityForm({ config, onChange }: PersonalityFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const multipleReplyStyleText = config.multiple_reply_style.join('\n')
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -345,48 +346,49 @@ export function PersonalityForm({ config, onChange }: PersonalityFormProps) {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<Label htmlFor="interest">{t('setupPage.forms.personality.interest.label')}</Label> <Label htmlFor="multiple_reply_style">
<Textarea {t('setupPage.forms.personality.multipleReplyStyle.label')}
id="interest"
placeholder={t('setupPage.forms.personality.interest.placeholder')}
value={config.interest}
onChange={(e) => onChange({ ...config, interest: e.target.value })}
rows={2}
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.personality.interest.description')}
</p>
</div>
<Separator />
<div className="space-y-3">
<Label htmlFor="plan_style">{t('setupPage.forms.personality.planStyle.label')}</Label>
<Textarea
id="plan_style"
placeholder={t('setupPage.forms.personality.planStyle.placeholder')}
value={config.plan_style}
onChange={(e) => onChange({ ...config, plan_style: e.target.value })}
rows={4}
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.personality.planStyle.description')}
</p>
</div>
<div className="space-y-3">
<Label htmlFor="private_plan_style">
{t('setupPage.forms.personality.privatePlanStyle.label')}
</Label> </Label>
<Textarea <Textarea
id="private_plan_style" id="multiple_reply_style"
placeholder={t('setupPage.forms.personality.privatePlanStyle.placeholder')} placeholder={t('setupPage.forms.personality.multipleReplyStyle.placeholder')}
value={config.private_plan_style} value={multipleReplyStyleText}
onChange={(e) => onChange({ ...config, private_plan_style: e.target.value })} onChange={(e) =>
rows={3} onChange({
...config,
multiple_reply_style: e.target.value
.split('\n')
.map((style) => style.trim())
.filter(Boolean),
})
}
rows={5}
/> />
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{t('setupPage.forms.personality.privatePlanStyle.description')} {t('setupPage.forms.personality.multipleReplyStyle.description')}
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="multiple_probability">
{t('setupPage.forms.personality.multipleProbability.label')}
</Label>
<span className="text-muted-foreground text-sm">
{(config.multiple_probability * 100).toFixed(0)}%
</span>
</div>
<Input
id="multiple_probability"
type="range"
min="0"
max="1"
step="0.1"
value={config.multiple_probability}
onChange={(e) => onChange({ ...config, multiple_probability: Number(e.target.value) })}
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.personality.multipleProbability.description')}
</p> </p>
</div> </div>
</div> </div>
@@ -405,23 +407,17 @@ export function EmojiForm({ config, onChange }: EmojiFormProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <Label htmlFor="emoji_send_num">{t('setupPage.forms.emoji.emojiSendNum.label')}</Label>
<Label htmlFor="emoji_chance">{t('setupPage.forms.emoji.emojiChance.label')}</Label>
<span className="text-muted-foreground text-sm">
{(config.emoji_chance * 100).toFixed(0)}%
</span>
</div>
<Input <Input
id="emoji_chance" id="emoji_send_num"
type="range" type="number"
min="0" min="1"
max="1" max="64"
step="0.1" value={config.emoji_send_num}
value={config.emoji_chance} onChange={(e) => onChange({ ...config, emoji_send_num: Number(e.target.value) })}
onChange={(e) => onChange({ ...config, emoji_chance: Number(e.target.value) })}
/> />
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{t('setupPage.forms.emoji.emojiChance.description')} {t('setupPage.forms.emoji.emojiSendNum.description')}
</p> </p>
</div> </div>
@@ -532,22 +528,6 @@ export function OtherBasicForm({ config, onChange }: OtherBasicFormProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="enable_tool">{t('setupPage.forms.other.enableTool.label')}</Label>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.other.enableTool.description')}
</p>
</div>
<Switch
id="enable_tool"
checked={config.enable_tool}
onCheckedChange={(checked) => onChange({ ...config, enable_tool: checked })}
/>
</div>
<Separator />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="all_global">{t('setupPage.forms.other.allGlobal.label')}</Label> <Label htmlFor="all_global">{t('setupPage.forms.other.allGlobal.label')}</Label>

View File

@@ -51,9 +51,8 @@ export async function loadPersonalityConfig(): Promise<PersonalityConfig> {
return { return {
personality: personalityConfig.personality || '', personality: personalityConfig.personality || '',
reply_style: personalityConfig.reply_style || '', reply_style: personalityConfig.reply_style || '',
interest: personalityConfig.interest || '', multiple_reply_style: personalityConfig.multiple_reply_style || [],
plan_style: personalityConfig.plan_style || '', multiple_probability: personalityConfig.multiple_probability ?? 0.2,
private_plan_style: personalityConfig.private_plan_style || '',
} }
} }
@@ -71,8 +70,8 @@ export async function loadEmojiConfig(): Promise<EmojiConfig> {
const emojiConfig = (data.config.emoji || {}) as Partial<EmojiConfig> const emojiConfig = (data.config.emoji || {}) as Partial<EmojiConfig>
return { return {
emoji_chance: emojiConfig.emoji_chance ?? 0.4, emoji_send_num: emojiConfig.emoji_send_num ?? 25,
max_reg_num: emojiConfig.max_reg_num ?? 40, max_reg_num: emojiConfig.max_reg_num ?? 64,
do_replace: emojiConfig.do_replace ?? true, do_replace: emojiConfig.do_replace ?? true,
check_interval: emojiConfig.check_interval ?? 10, check_interval: emojiConfig.check_interval ?? 10,
steal_emoji: emojiConfig.steal_emoji ?? true, steal_emoji: emojiConfig.steal_emoji ?? true,
@@ -90,18 +89,15 @@ export async function loadOtherBasicConfig(): Promise<OtherBasicConfig> {
const result = await parseResponse<{ const result = await parseResponse<{
config: { config: {
tool?: { enable_tool?: boolean }
expression?: { all_global_jargon?: boolean } expression?: { all_global_jargon?: boolean }
} }
}>(response) }>(response)
const data = throwIfError(result) const data = throwIfError(result)
const config = data.config const config = data.config
const toolConfig = config.tool || {}
const expressionConfig = config.expression || {} const expressionConfig = config.expression || {}
return { return {
enable_tool: toolConfig.enable_tool ?? true,
all_global: expressionConfig.all_global_jargon ?? true, all_global: expressionConfig.all_global_jargon ?? true,
} }
} }
@@ -169,38 +165,16 @@ export async function saveEmojiConfig(config: EmojiConfig) {
return throwIfError(result) return throwIfError(result)
} }
// 保存其他基础配置(工具、情绪、黑话) // 保存其他基础配置(黑话)
export async function saveOtherBasicConfig(config: OtherBasicConfig) { export async function saveOtherBasicConfig(config: OtherBasicConfig) {
// 需要分别保存到不同的section const response = await fetchWithAuth('/api/webui/config/bot/section/expression', {
const promises = [] method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ all_global_jargon: config.all_global }),
})
// 保存tool配置 const result = await parseResponse(response)
promises.push( return throwIfError(result)
fetchWithAuth('/api/webui/config/bot/section/tool', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ enable_tool: config.enable_tool }),
})
)
// 保存expression配置中的all_global_jargon
promises.push(
fetchWithAuth('/api/webui/config/bot/section/expression', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ all_global_jargon: config.all_global }),
})
)
const results = await Promise.all(promises)
// 检查所有请求是否成功
for (const response of results) {
const result = await parseResponse(response)
throwIfError(result)
}
return { success: true }
} }
// 保存硅基流动API配置 // 保存硅基流动API配置

View File

@@ -95,13 +95,17 @@ function SetupPageContent() {
const createDefaultPersonalityConfig = (): PersonalityConfig => ({ const createDefaultPersonalityConfig = (): PersonalityConfig => ({
personality: t('setupPage.defaults.personality.personality'), personality: t('setupPage.defaults.personality.personality'),
reply_style: t('setupPage.defaults.personality.replyStyle'), reply_style: t('setupPage.defaults.personality.replyStyle'),
interest: t('setupPage.defaults.personality.interest'), multiple_reply_style: [
plan_style: t('setupPage.defaults.personality.planStyle'), t('setupPage.defaults.personality.multipleReplyStyles.plain'),
private_plan_style: t('setupPage.defaults.personality.privatePlanStyle'), t('setupPage.defaults.personality.multipleReplyStyles.shortText'),
t('setupPage.defaults.personality.multipleReplyStyles.shortSymbol'),
t('setupPage.defaults.personality.multipleReplyStyles.translation'),
],
multiple_probability: 0.2,
}) })
const createDefaultEmojiConfig = (): EmojiConfig => ({ const createDefaultEmojiConfig = (): EmojiConfig => ({
emoji_chance: 0.4, emoji_send_num: 25,
max_reg_num: 40, max_reg_num: 64,
do_replace: true, do_replace: true,
check_interval: 10, check_interval: 10,
steal_emoji: true, steal_emoji: true,
@@ -132,7 +136,6 @@ function SetupPageContent() {
// 步骤4其他基础配置 // 步骤4其他基础配置
const [otherBasic, setOtherBasic] = useState<OtherBasicConfig>({ const [otherBasic, setOtherBasic] = useState<OtherBasicConfig>({
enable_tool: true,
all_global: true, all_global: true,
}) })

View File

@@ -20,14 +20,13 @@ export interface BotBasicConfig {
export interface PersonalityConfig { export interface PersonalityConfig {
personality: string personality: string
reply_style: string reply_style: string
interest: string multiple_reply_style: string[]
plan_style: string multiple_probability: number
private_plan_style: string
} }
// 步骤3表情包配置 // 步骤3表情包配置
export interface EmojiConfig { export interface EmojiConfig {
emoji_chance: number emoji_send_num: number
max_reg_num: number max_reg_num: number
do_replace: boolean do_replace: boolean
check_interval: number check_interval: number
@@ -38,7 +37,6 @@ export interface EmojiConfig {
// 步骤4其他基础配置 // 步骤4其他基础配置
export interface OtherBasicConfig { export interface OtherBasicConfig {
enable_tool: boolean
all_global: boolean // 全局黑话模式expression.all_global_jargon all_global: boolean // 全局黑话模式expression.all_global_jargon
} }