Merge branch 'dev' of https://github.com/Mai-with-u/MaiBot into dev
This commit is contained in:
@@ -6,7 +6,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{ ignores: ['dist', 'out'] },
|
||||
jsxA11y.flatConfigs.recommended,
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
@@ -25,10 +25,7 @@ export default tseslint.config(
|
||||
acc[key] = 'warn'
|
||||
return acc
|
||||
}, {}),
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
// 关闭或降级其他规则
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
@@ -37,4 +34,11 @@ export default tseslint.config(
|
||||
'jsx-a11y/no-autofocus': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.d.ts'],
|
||||
rules: {
|
||||
// Ambient global declarations use `var` in TypeScript declaration files.
|
||||
'no-var': 'off',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-spring/web": "10.0.3",
|
||||
"@tanstack/react-router": "^1.140.0",
|
||||
"@tanstack/react-virtual": "^3.13.13",
|
||||
"@tanstack/router-devtools": "^1.140.0",
|
||||
@@ -130,6 +131,7 @@
|
||||
"@uppy/dashboard": "^5.1.0",
|
||||
"@uppy/react": "^5.1.1",
|
||||
"@uppy/xhr-upload": "^5.1.1",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -142,6 +144,7 @@
|
||||
"idb": "^8.0.3",
|
||||
"katex": "^0.16.27",
|
||||
"lucide-react": "^0.556.0",
|
||||
"motion": "^12.38.0",
|
||||
"react": "^19.2.1",
|
||||
"react-day-picker": "^9.12.0",
|
||||
"react-dom": "^19.2.1",
|
||||
@@ -154,9 +157,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"smol-toml": "^1.5.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"@react-spring/web": "10.0.3",
|
||||
"@use-gesture/react": "^10.3.1"
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import { useEffect, useState } 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'
|
||||
import { lazy, Suspense } from 'react'
|
||||
|
||||
export type Language = 'python' | 'json' | 'toml' | 'css' | 'text'
|
||||
|
||||
interface CodeEditorProps {
|
||||
export interface CodeEditorProps {
|
||||
value: string
|
||||
|
||||
onChange?: (value: string) => void
|
||||
@@ -27,109 +16,38 @@ interface CodeEditorProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
// 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: [],
|
||||
}
|
||||
const CodeEditorImpl = lazy(() => import('./CodeEditorImpl'))
|
||||
|
||||
export function CodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
language = 'text',
|
||||
readOnly = false,
|
||||
height = '400px',
|
||||
function CodeEditorFallback({
|
||||
height,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
placeholder,
|
||||
theme,
|
||||
className = '',
|
||||
}: CodeEditorProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { resolvedTheme } = useTheme()
|
||||
}: Pick<CodeEditorProps, 'height' | 'minHeight' | 'maxHeight' | 'className'>) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-muted animate-pulse rounded-md border ${className}`}
|
||||
style={{ height, minHeight, maxHeight }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
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
|
||||
export function CodeEditor(props: CodeEditorProps) {
|
||||
const { height = '400px', minHeight, maxHeight, className = '' } = props
|
||||
|
||||
return (
|
||||
<div className={`rounded-md overflow-hidden border custom-scrollbar ${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>
|
||||
<Suspense
|
||||
fallback={
|
||||
<CodeEditorFallback
|
||||
height={height}
|
||||
minHeight={minHeight}
|
||||
maxHeight={maxHeight}
|
||||
className={className}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CodeEditorImpl {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
105
dashboard/src/components/CodeEditorImpl.tsx
Normal file
105
dashboard/src/components/CodeEditorImpl.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1443,6 +1443,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
|
||||
<div
|
||||
className="relative w-full max-w-md h-[400px] flex items-center justify-center"
|
||||
role="listbox"
|
||||
tabIndex={0}
|
||||
aria-label="待审核的表达方式"
|
||||
aria-activedescendant={quickExpressions[quickCurrentIndex] ? `quick-expr-${quickExpressions[quickCurrentIndex].id}` : undefined}
|
||||
>
|
||||
@@ -1561,14 +1562,14 @@ if (isCurrent) {
|
||||
</div>
|
||||
{/* 情景 */}
|
||||
<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">
|
||||
<p className="text-lg font-medium leading-relaxed">{expr.situation}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* 风格 */}
|
||||
<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">
|
||||
{expr.style.split(/[,,]/).map((s, i) => (
|
||||
<Badge key={i} variant="secondary" className="font-normal">
|
||||
@@ -1614,14 +1615,14 @@ if (isCurrent) {
|
||||
</div>
|
||||
{/* 情景 */}
|
||||
<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">
|
||||
<p className="text-lg font-medium leading-relaxed">{expr.situation}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* 风格 */}
|
||||
<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">
|
||||
{expr.style.split(/[,,]/).map((s, i) => (
|
||||
<Badge key={i} variant="secondary" className="font-normal">
|
||||
|
||||
@@ -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 { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -13,14 +27,21 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ShortcutKbd } from '@/components/ui/kbd'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { toggleThemeWithTransition } from '@/components/use-theme'
|
||||
import { useBackground } from '@/hooks/use-background'
|
||||
import { logout } from '@/lib/fetch-with-auth'
|
||||
import { isElectron } from '@/lib/runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { WorkspaceMode } from './types'
|
||||
|
||||
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 {
|
||||
sidebarOpen: boolean
|
||||
@@ -31,6 +52,7 @@ interface HeaderProps {
|
||||
onMobileMenuToggle: () => void
|
||||
onSearchOpenChange: (open: boolean) => void
|
||||
onThemeChange: (theme: 'light' | 'dark' | 'system') => void
|
||||
workspaceMode: WorkspaceMode
|
||||
}
|
||||
|
||||
export function Header({
|
||||
@@ -42,6 +64,7 @@ export function Header({
|
||||
onMobileMenuToggle,
|
||||
onSearchOpenChange,
|
||||
onThemeChange,
|
||||
workspaceMode,
|
||||
}: HeaderProps) {
|
||||
const { t, i18n: i18nInstance } = useTranslation()
|
||||
const currentLang = i18nInstance.language || 'zh'
|
||||
@@ -62,10 +85,12 @@ export function Header({
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={cn(
|
||||
'sticky top-0 z-10 flex h-16 items-center justify-between border-b px-4 backdrop-blur-md isolate',
|
||||
inheritsPageBackground ? 'bg-transparent' : 'bg-card/80',
|
||||
)}>
|
||||
<header
|
||||
className={cn(
|
||||
'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" />}
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
{/* 移动端菜单按钮 */}
|
||||
@@ -73,17 +98,23 @@ export function Header({
|
||||
onClick={onMobileMenuToggle}
|
||||
aria-label={t('a11y.closeMenu')}
|
||||
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" />
|
||||
</button>
|
||||
|
||||
|
||||
{/* 桌面端侧边栏收起/展开按钮 */}
|
||||
<button
|
||||
onClick={onSidebarToggle}
|
||||
aria-label={sidebarOpen ? t('header.collapseSidebar') : t('header.expandSidebar')}
|
||||
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
|
||||
className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')}
|
||||
@@ -92,6 +123,49 @@ export function Header({
|
||||
</div>
|
||||
|
||||
<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) */}
|
||||
{isElectron() && (
|
||||
<>
|
||||
@@ -103,23 +177,30 @@ export function Header({
|
||||
title={t('header.toggleConnection')}
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
</Button>
|
||||
<BackendManager open={backendManagerOpen} onOpenChange={setBackendManagerOpen} />
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<div className="bg-border h-6 w-px" />
|
||||
</>
|
||||
)}
|
||||
{/* 搜索框 */}
|
||||
<button
|
||||
onClick={() => onSearchOpenChange(true)}
|
||||
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" />
|
||||
<span className="text-sm text-muted-foreground">{t('header.searchPlaceholder')}</span>
|
||||
<ShortcutKbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2" keys={['mod', 'k']} />
|
||||
<Search
|
||||
className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
|
||||
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>
|
||||
|
||||
{/* 搜索对话框 */}
|
||||
@@ -142,26 +223,23 @@ export function Header({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="hidden sm:inline text-xs">
|
||||
{LANGUAGE_NAMES[currentLang.split('-')[0] as 'zh' | 'en' | 'ja' | 'ko'] ?? currentLang}
|
||||
<span className="hidden text-xs sm:inline">
|
||||
{LANGUAGE_NAMES[currentLang.split('-')[0] as 'zh' | 'en' | 'ja' | 'ko'] ??
|
||||
currentLang}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{
|
||||
|
||||
LANGUAGE_CODES.map((code) => (
|
||||
{LANGUAGE_CODES.map((code) => (
|
||||
<DropdownMenuItem
|
||||
key={code}
|
||||
onClick={() => i18nInstance.changeLanguage(code)}
|
||||
className={cn(
|
||||
'cursor-pointer',
|
||||
currentLang.split('-')[0] === code && 'font-semibold text-primary'
|
||||
currentLang.split('-')[0] === code && 'text-primary font-semibold'
|
||||
)}
|
||||
>
|
||||
{currentLang.split('-')[0] === code && (
|
||||
<span className="mr-2">✓</span>
|
||||
)}
|
||||
{currentLang.split('-')[0] === code && <span className="mr-2">✓</span>}
|
||||
{LANGUAGE_NAMES[code]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@@ -175,13 +253,13 @@ export function Header({
|
||||
toggleThemeWithTransition(newTheme, onThemeChange, e)
|
||||
}}
|
||||
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" />}
|
||||
</button>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<div className="bg-border h-6 w-px" />
|
||||
|
||||
{/* 登出按钮 */}
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
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 { BackToTop } from '@/components/back-to-top'
|
||||
@@ -25,8 +26,11 @@ export function Layout({ children }: LayoutProps) {
|
||||
const { t } = useTranslation()
|
||||
const { checking } = useAuthGuard() // 检查认证状态
|
||||
const router = useRouter()
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname })
|
||||
const announce = useAnnounce()
|
||||
|
||||
const workspaceMode = pathname.startsWith('/chat') ? 'chat' : 'settings'
|
||||
const isChatWorkspace = workspaceMode === 'chat'
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
@@ -55,7 +59,7 @@ export function Layout({ children }: LayoutProps) {
|
||||
setSearchOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
@@ -68,12 +72,12 @@ export function Layout({ children }: LayoutProps) {
|
||||
pathToLabel[item.path] = t(item.label)
|
||||
}
|
||||
}
|
||||
pathToLabel['/chat'] = t('workspace.chat')
|
||||
|
||||
return router.subscribe('onResolved', () => {
|
||||
const pageTitle = pathToLabel[router.state.location.pathname] ?? 'MaiBot Dashboard'
|
||||
const fullTitle = pageTitle === 'MaiBot Dashboard'
|
||||
? 'MaiBot Dashboard'
|
||||
: `${pageTitle} — MaiBot Dashboard`
|
||||
const fullTitle =
|
||||
pageTitle === 'MaiBot Dashboard' ? 'MaiBot Dashboard' : `${pageTitle} — MaiBot Dashboard`
|
||||
|
||||
// 更新 document.title
|
||||
document.title = fullTitle
|
||||
@@ -106,71 +110,129 @@ export function Layout({ children }: LayoutProps) {
|
||||
// 认证检查中,显示加载状态
|
||||
if (checking) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<SkipNav />
|
||||
{isElectron() && <TitleBar />}
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<SkipNav />
|
||||
{isElectron() && <TitleBar />}
|
||||
<div className={cn('relative isolate flex h-screen overflow-hidden', isElectron() && 'pt-8')}>
|
||||
<BackgroundLayer config={pageBg} layerId="page" />
|
||||
<div className="relative z-10 flex h-full w-full overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
sidebarOpen={sidebarOpen}
|
||||
mobileMenuOpen={mobileMenuOpen}
|
||||
tooltipsEnabled={tooltipsEnabled}
|
||||
onMobileMenuClose={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<BackgroundLayer config={pageBg} layerId="page" />
|
||||
<div className="relative z-10 flex h-full w-full overflow-hidden">
|
||||
{/* Sidebar:仅在设置工作区显示,伴随滑入/滑出动画 */}
|
||||
<AnimatePresence initial={false}>
|
||||
{!isChatWorkspace && (
|
||||
<motion.div
|
||||
key="settings-sidebar"
|
||||
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 */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
|
||||
)}
|
||||
{/* 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}
|
||||
/>
|
||||
|
||||
{/* 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',
|
||||
{/* 移动端 Sidebar 走自己的 fixed 定位,通过 mobileMenuOpen 控制显隐 */}
|
||||
{!isChatWorkspace && (
|
||||
<div className="lg:hidden">
|
||||
<Sidebar
|
||||
sidebarOpen={sidebarOpen}
|
||||
mobileMenuOpen={mobileMenuOpen}
|
||||
tooltipsEnabled={tooltipsEnabled}
|
||||
onMobileMenuClose={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="relative z-10 h-full">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Back to Top Button */}
|
||||
<BackToTop />
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile overlay */}
|
||||
<AnimatePresence>
|
||||
{!isChatWorkspace && mobileMenuOpen && (
|
||||
<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>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
@@ -37,7 +37,6 @@ export const menuSections: MenuSection[] = [
|
||||
{ icon: Sliders, label: 'sidebar.menu.pluginConfig', path: '/plugin-config' },
|
||||
{ icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' },
|
||||
{ icon: Activity, label: 'sidebar.menu.maisakaMonitor', path: '/planner-monitor' },
|
||||
{ icon: MessageSquare, label: 'sidebar.menu.localChat', path: '/chat' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export type WorkspaceMode = 'settings' | 'chat'
|
||||
|
||||
export interface MenuItem {
|
||||
icon: ComponentType<LucideProps>
|
||||
label: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
@@ -43,11 +43,122 @@
|
||||
"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": {
|
||||
"verifyingLogin": "Verifying login status...",
|
||||
"logoTitle": "MaiBot WebUI",
|
||||
"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": {
|
||||
"title": "Settings",
|
||||
"description": "Manage your application preferences",
|
||||
@@ -395,7 +506,7 @@
|
||||
},
|
||||
"other": {
|
||||
"title": "Other Settings",
|
||||
"description": "Configure tools, emotion system, and more"
|
||||
"description": "Configure global slang and other basic options"
|
||||
},
|
||||
"siliconFlow": {
|
||||
"title": "API Setup",
|
||||
@@ -450,9 +561,12 @@
|
||||
"personality": {
|
||||
"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.",
|
||||
"interest": "Interested in technology, games, anime, and everyday topics, and dislikes topics that are overly heavy or serious.",
|
||||
"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.",
|
||||
"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."
|
||||
"multipleReplyStyles": {
|
||||
"plain": "Your style is plain, lightly sarcastic, very short, and colloquial. You can reference Tieba and Weibo reply styles.",
|
||||
"shortText": "Reply with 1-2 words",
|
||||
"shortSymbol": "Reply with 1-2 symbols",
|
||||
"translation": "Use a slightly translated tone, but keep it short"
|
||||
}
|
||||
},
|
||||
"emoji": {
|
||||
"filtrationPrompt": "Appropriate and safe for general audiences"
|
||||
@@ -506,26 +620,20 @@
|
||||
"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"
|
||||
},
|
||||
"interest": {
|
||||
"label": "Interests *",
|
||||
"placeholder": "Describe the topics the bot is interested in",
|
||||
"description": "This affects which topics the bot is more likely to respond to"
|
||||
"multipleReplyStyle": {
|
||||
"label": "Alternate Reply Styles",
|
||||
"placeholder": "Enter one alternate reply style per line",
|
||||
"description": "When this list is not empty, MaiBot may randomly replace the default reply style using the probability below"
|
||||
},
|
||||
"planStyle": {
|
||||
"label": "Group Chat Rules *",
|
||||
"placeholder": "The bot's behavior style and rules in group chats",
|
||||
"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"
|
||||
"multipleProbability": {
|
||||
"label": "Alternate Style Probability",
|
||||
"description": "The chance of randomly replacing the default reply style each time a reply is built"
|
||||
}
|
||||
},
|
||||
"emoji": {
|
||||
"emojiChance": {
|
||||
"label": "Emoji Activation Chance",
|
||||
"description": "How likely the bot is to send an emoji"
|
||||
"emojiSendNum": {
|
||||
"label": "Emoji Candidate Count",
|
||||
"description": "How many emojis to choose from before sending. Maximum is 64"
|
||||
},
|
||||
"maxRegNum": {
|
||||
"label": "Maximum Emoji Count",
|
||||
@@ -554,10 +662,6 @@
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"enableTool": {
|
||||
"label": "Enable Tool System",
|
||||
"description": "Allow the bot to use tools to enhance its capabilities"
|
||||
},
|
||||
"allGlobal": {
|
||||
"label": "Enable Global Slang Mode",
|
||||
"description": "Allow the bot to learn and use group-specific slang"
|
||||
|
||||
@@ -43,11 +43,122 @@
|
||||
"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": {
|
||||
"verifyingLogin": "ログイン状態を確認中...",
|
||||
"logoTitle": "MaiBot WebUI",
|
||||
"logoTitleShort": "M"
|
||||
},
|
||||
"httpWarning": {
|
||||
"title": "セキュリティ通知:",
|
||||
"message": "WebUI に HTTP でアクセスしています。",
|
||||
"description": "ローカル以外の HTTP 接続では、アクセストークンや設定内容が漏えいする可能性があります。可能であれば HTTPS または信頼できる内網接続を使用してください。",
|
||||
"dismiss": "HTTP セキュリティ通知を閉じる"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
"description": "アプリの設定を管理する",
|
||||
@@ -395,7 +506,7 @@
|
||||
},
|
||||
"other": {
|
||||
"title": "その他の設定",
|
||||
"description": "ツールや感情システムなどを設定します"
|
||||
"description": "グローバルスラングなどの基本オプションを設定します"
|
||||
},
|
||||
"siliconFlow": {
|
||||
"title": "API設定",
|
||||
@@ -450,9 +561,12 @@
|
||||
"personality": {
|
||||
"personality": "女子大生で、現在大学2年生。掲示板を見るのが好き。",
|
||||
"replyStyle": "返信は淡々と、短めにし、中国語で話してください。自分の学科背景をわざと強調しないでください。Tieba、Zhihu、Weibo の返信スタイルを参考にできます。",
|
||||
"interest": "技術、ゲーム、アニメ、日常の話題に興味があり、重すぎたり厳粛すぎたりする話題は好みません。",
|
||||
"planStyle": "1. 利用可能な **すべて** の action の **各アクション** が現在の条件に合うか考え、会話内容に合えば使用してください\n2. 同じ内容がすでに実行されている場合は繰り返さないでください\n3. 発言頻度を調整し、発言しすぎないでください\n4. 誰かがあなたにうんざりしている場合は、返信を減らしてください\n5. 誰かがあなたを攻撃したり感情的になったりした場合は、適切に対応してください",
|
||||
"privatePlanStyle": "1. 利用可能な **すべて** の action の **各アクション** が現在の条件に合うか考え、会話内容に合えば使用してください\n2. 同じ内容がすでに実行されている場合は繰り返さないでください\n3. すでに返信した文には再度返信しないでください"
|
||||
"multipleReplyStyles": {
|
||||
"plain": "淡々として少し皮肉っぽく、とても短く口語的に返信します。Tieba や Weibo の返信スタイルを参考にできます。",
|
||||
"shortText": "1〜2文字で返信する",
|
||||
"shortSymbol": "1〜2個の記号で返信する",
|
||||
"translation": "少し翻訳調にするが、長くしない"
|
||||
}
|
||||
},
|
||||
"emoji": {
|
||||
"filtrationPrompt": "公序良俗に反しないこと"
|
||||
@@ -506,26 +620,20 @@
|
||||
"placeholder": "ボットの話し方や表現の癖を説明してください",
|
||||
"description": "例:返信は淡々と短めにし、中国語で話し、Tieba・Zhihu・Weibo の雰囲気を参考にする"
|
||||
},
|
||||
"interest": {
|
||||
"label": "興味 *",
|
||||
"placeholder": "ボットが興味を持つ話題を説明してください",
|
||||
"description": "どの話題に返信しやすくなるかに影響します"
|
||||
"multipleReplyStyle": {
|
||||
"label": "代替返信スタイル",
|
||||
"placeholder": "1行につき1つの代替返信スタイルを入力",
|
||||
"description": "リストが空でない場合、MaiBot は下の確率でデフォルトの返信スタイルをランダムに置き換えます"
|
||||
},
|
||||
"planStyle": {
|
||||
"label": "グループチャットのルール *",
|
||||
"placeholder": "グループチャットでの行動方針やルール",
|
||||
"description": "返信頻度や条件など、グループチャットでの振る舞いを定義します"
|
||||
},
|
||||
"privatePlanStyle": {
|
||||
"label": "個別チャットのルール *",
|
||||
"placeholder": "個別チャットでの行動方針やルール",
|
||||
"description": "個別チャットでの振る舞いを定義します"
|
||||
"multipleProbability": {
|
||||
"label": "代替スタイルの発動確率",
|
||||
"description": "返信を構築するたびに、代替返信スタイルでデフォルトを置き換える確率です"
|
||||
}
|
||||
},
|
||||
"emoji": {
|
||||
"emojiChance": {
|
||||
"label": "絵文字パック発動確率",
|
||||
"description": "ボットが絵文字を送る確率です"
|
||||
"emojiSendNum": {
|
||||
"label": "絵文字候補数",
|
||||
"description": "送信前にいくつの絵文字から選ぶかを指定します。最大 64 です"
|
||||
},
|
||||
"maxRegNum": {
|
||||
"label": "最大絵文字数",
|
||||
@@ -554,10 +662,6 @@
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"enableTool": {
|
||||
"label": "ツールシステムを有効にする",
|
||||
"description": "ボットが各種ツールを使って機能を拡張できるようにします"
|
||||
},
|
||||
"allGlobal": {
|
||||
"label": "グローバルスラングモードを有効にする",
|
||||
"description": "グループ内のスラングを学習して使えるようにします"
|
||||
|
||||
@@ -43,11 +43,122 @@
|
||||
"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": {
|
||||
"verifyingLogin": "로그인 상태 확인 중...",
|
||||
"logoTitle": "MaiBot WebUI",
|
||||
"logoTitleShort": "M"
|
||||
},
|
||||
"httpWarning": {
|
||||
"title": "보안 알림: ",
|
||||
"message": "현재 HTTP로 WebUI에 접속하고 있습니다.",
|
||||
"description": "로컬이 아닌 HTTP 연결에서는 액세스 토큰과 설정 내용이 노출될 수 있습니다. 가능하면 HTTPS 또는 신뢰할 수 있는 내부망 연결을 사용하세요.",
|
||||
"dismiss": "HTTP 보안 알림 닫기"
|
||||
},
|
||||
"settings": {
|
||||
"title": "설정",
|
||||
"description": "앱 환경 설정 관리",
|
||||
@@ -395,7 +506,7 @@
|
||||
},
|
||||
"other": {
|
||||
"title": "기타 설정",
|
||||
"description": "도구, 감정 시스템 등의 설정을 구성합니다"
|
||||
"description": "전역 슬랭 등 기본 옵션을 설정합니다"
|
||||
},
|
||||
"siliconFlow": {
|
||||
"title": "API 설정",
|
||||
@@ -450,9 +561,12 @@
|
||||
"personality": {
|
||||
"personality": "여자 대학생이며 현재 2학년이고, Tieba 같은 커뮤니티를 자주 봅니다.",
|
||||
"replyStyle": "답변은 담백하고 짧게 하며 중국어로 말하세요. 자신의 학과 배경을 일부러 강조하지 마세요. Tieba, Zhihu, Weibo의 답변 스타일을 참고할 수 있습니다.",
|
||||
"interest": "기술, 게임, 애니메이션, 일상적인 주제에 관심이 있고, 너무 무겁거나 엄숙한 주제는 좋아하지 않습니다.",
|
||||
"planStyle": "1. 사용 가능한 **모든** action 의 **각 동작** 이 현재 조건에 맞는지 검토하고, 대화 내용에 맞으면 사용하세요\n2. 같은 내용이 이미 실행되었다면 반복하지 마세요\n3. 발화 빈도를 조절하고 너무 자주 말하지 마세요\n4. 누군가 당신을 귀찮아하는 것 같다면 답장을 줄이세요\n5. 누군가 당신을 공격하거나 감정적으로 반응하면 적절하게 대응하세요",
|
||||
"privatePlanStyle": "1. 사용 가능한 **모든** action 의 **각 동작** 이 현재 조건에 맞는지 검토하고, 대화 내용에 맞으면 사용하세요\n2. 같은 내용이 이미 실행되었다면 반복하지 마세요\n3. 이미 답한 문장에는 다시 답하지 마세요"
|
||||
"multipleReplyStyles": {
|
||||
"plain": "말투는 담백하지만 약간 냉소적이고, 매우 짧고 구어체입니다. Tieba 와 Weibo 답변 스타일을 참고할 수 있습니다.",
|
||||
"shortText": "1-2글자로 답장하기",
|
||||
"shortSymbol": "1-2개의 기호로 답장하기",
|
||||
"translation": "살짝 번역체로 말하되 길게 쓰지 않기"
|
||||
}
|
||||
},
|
||||
"emoji": {
|
||||
"filtrationPrompt": "공공질서와 미풍양속에 어긋나지 않음"
|
||||
@@ -506,26 +620,20 @@
|
||||
"placeholder": "봇이 말하는 방식과 표현 습관을 설명해 주세요",
|
||||
"description": "예: 답변은 담백하고 짧게, 중국어로 말하며 Tieba, Zhihu, Weibo 스타일을 참고한다"
|
||||
},
|
||||
"interest": {
|
||||
"label": "관심사 *",
|
||||
"placeholder": "봇이 관심을 가지는 주제를 설명해 주세요",
|
||||
"description": "어떤 주제에 더 잘 반응할지에 영향을 줍니다"
|
||||
"multipleReplyStyle": {
|
||||
"label": "대체 답변 스타일",
|
||||
"placeholder": "한 줄에 하나씩 대체 답변 스타일을 입력하세요",
|
||||
"description": "목록이 비어 있지 않으면 MaiBot 이 아래 확률에 따라 기본 답변 스타일을 무작위로 대체합니다"
|
||||
},
|
||||
"planStyle": {
|
||||
"label": "그룹 채팅 규칙 *",
|
||||
"placeholder": "그룹 채팅에서의 행동 스타일과 규칙",
|
||||
"description": "답장 빈도와 조건 등 그룹 채팅에서의 행동 방식을 정의합니다"
|
||||
},
|
||||
"privatePlanStyle": {
|
||||
"label": "개인 채팅 규칙 *",
|
||||
"placeholder": "개인 채팅에서의 행동 스타일과 규칙",
|
||||
"description": "개인 채팅에서의 행동 방식을 정의합니다"
|
||||
"multipleProbability": {
|
||||
"label": "대체 스타일 적용 확률",
|
||||
"description": "답변을 만들 때마다 기본 답변 스타일을 대체 스타일로 무작위 교체할 확률입니다"
|
||||
}
|
||||
},
|
||||
"emoji": {
|
||||
"emojiChance": {
|
||||
"label": "이모지 활성화 확률",
|
||||
"description": "봇이 이모지를 보낼 확률입니다"
|
||||
"emojiSendNum": {
|
||||
"label": "이모지 후보 수",
|
||||
"description": "전송 전에 몇 개의 이모지 중에서 고를지 정합니다. 최대 64개입니다"
|
||||
},
|
||||
"maxRegNum": {
|
||||
"label": "최대 이모지 수",
|
||||
@@ -554,10 +662,6 @@
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"enableTool": {
|
||||
"label": "도구 시스템 사용",
|
||||
"description": "봇이 다양한 도구를 사용해 기능을 확장할 수 있게 합니다"
|
||||
},
|
||||
"allGlobal": {
|
||||
"label": "전역 슬랭 모드 사용",
|
||||
"description": "봇이 그룹 슬랭을 학습하고 사용할 수 있게 합니다"
|
||||
|
||||
@@ -43,11 +43,122 @@
|
||||
"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": {
|
||||
"verifyingLogin": "正在验证登录状态...",
|
||||
"logoTitle": "MaiBot WebUI",
|
||||
"logoTitleShort": "M"
|
||||
},
|
||||
"httpWarning": {
|
||||
"title": "安全提示:",
|
||||
"message": "当前正在通过 HTTP 访问 WebUI。",
|
||||
"description": "非本地 HTTP 连接可能暴露访问令牌和配置内容,建议改用 HTTPS 或受信任的内网连接。",
|
||||
"dismiss": "关闭 HTTP 安全提示"
|
||||
},
|
||||
"settings": {
|
||||
"title": "系统设置",
|
||||
"description": "管理您的应用偏好设置",
|
||||
@@ -395,7 +506,7 @@
|
||||
},
|
||||
"other": {
|
||||
"title": "其他设置",
|
||||
"description": "工具、情绪系统等配置"
|
||||
"description": "配置全局黑话等基础选项"
|
||||
},
|
||||
"siliconFlow": {
|
||||
"title": "API配置",
|
||||
@@ -450,9 +561,12 @@
|
||||
"personality": {
|
||||
"personality": "是一个女大学生,现在在读大二,会刷贴吧。",
|
||||
"replyStyle": "请回复得平淡一些,简短一些,说中文,不要刻意突出自身学科背景。可以参考贴吧、知乎和微博的回复风格。",
|
||||
"interest": "对技术相关话题、游戏和动漫相关话题感兴趣,也对日常话题感兴趣,不喜欢太过沉重严肃的话题。",
|
||||
"planStyle": "1.思考**所有**的可用的 action 中的**每个动作**是否符合当下条件,如果动作使用条件符合聊天内容就使用\n2.如果相同的内容已经被执行,请不要重复执行\n3.请控制你的发言频率,不要太过频繁地发言\n4.如果有人对你感到厌烦,请减少回复\n5.如果有人对你进行攻击,或者情绪激动,请你以合适的方法应对",
|
||||
"privatePlanStyle": "1.思考**所有**的可用的 action 中的**每个动作**是否符合当下条件,如果动作使用条件符合聊天内容就使用\n2.如果相同的内容已经被执行,请不要重复执行\n3.某句话如果已经被回复过,不要重复回复"
|
||||
"multipleReplyStyles": {
|
||||
"plain": "你的风格平淡但不失讽刺,很简短,很白话。可以参考贴吧、微博的回复风格。",
|
||||
"shortText": "用1-2个字进行回复",
|
||||
"shortSymbol": "用1-2个符号进行回复",
|
||||
"translation": "带点翻译腔,但不要太长"
|
||||
}
|
||||
},
|
||||
"emoji": {
|
||||
"filtrationPrompt": "符合公序良俗"
|
||||
@@ -506,26 +620,20 @@
|
||||
"placeholder": "描述机器人说话的表达风格、表达习惯",
|
||||
"description": "例如:回复平淡一些,简短一些,说中文,参考贴吧、知乎和微博的回复风格"
|
||||
},
|
||||
"interest": {
|
||||
"label": "兴趣 *",
|
||||
"placeholder": "描述机器人感兴趣的话题",
|
||||
"description": "会影响机器人对什么话题进行回复"
|
||||
"multipleReplyStyle": {
|
||||
"label": "备用表达风格",
|
||||
"placeholder": "每行输入一种备用表达风格",
|
||||
"description": "当列表不为空时,麦麦会按概率从这些风格中随机替换默认表达风格"
|
||||
},
|
||||
"planStyle": {
|
||||
"label": "群聊说话规则 *",
|
||||
"placeholder": "机器人在群聊中的行为风格和规则",
|
||||
"description": "定义机器人在群聊中如何行动,例如回复频率、条件等"
|
||||
},
|
||||
"privatePlanStyle": {
|
||||
"label": "私聊说话规则 *",
|
||||
"placeholder": "机器人在私聊中的行为风格和规则",
|
||||
"description": "定义机器人在私聊中的行为方式"
|
||||
"multipleProbability": {
|
||||
"label": "备用风格触发概率",
|
||||
"description": "每次构建回复时,从备用表达风格中随机替换默认表达风格的概率"
|
||||
}
|
||||
},
|
||||
"emoji": {
|
||||
"emojiChance": {
|
||||
"label": "表情包激活概率",
|
||||
"description": "机器人发送表情包的概率"
|
||||
"emojiSendNum": {
|
||||
"label": "表情包候选数量",
|
||||
"description": "每次发送前从多少个表情包中选择,最大为 64"
|
||||
},
|
||||
"maxRegNum": {
|
||||
"label": "最大表情包数量",
|
||||
@@ -554,10 +662,6 @@
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"enableTool": {
|
||||
"label": "启用工具系统",
|
||||
"description": "允许机器人使用各种工具增强功能"
|
||||
},
|
||||
"allGlobal": {
|
||||
"label": "启用全局黑话模式",
|
||||
"description": "允许机器人学习和使用群组黑话"
|
||||
|
||||
@@ -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 { 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 { 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 { checkAuth } from './hooks/use-auth'
|
||||
import { RouteErrorBoundary } from './components/error-boundary'
|
||||
@@ -50,14 +32,14 @@ const rootRoute = createRootRoute({
|
||||
const authRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/auth',
|
||||
component: AuthPage,
|
||||
component: lazyRouteComponent(() => import('./routes/auth'), 'AuthPage'),
|
||||
})
|
||||
|
||||
// 首次配置路由(无 Layout)
|
||||
const setupRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/setup',
|
||||
component: SetupPage,
|
||||
component: lazyRouteComponent(() => import('./routes/setup/index.tsx'), 'SetupPage'),
|
||||
})
|
||||
|
||||
// 受保护的路由 Root(带 Layout)
|
||||
@@ -76,168 +58,192 @@ const protectedRoute = createRoute({
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/',
|
||||
component: IndexPage,
|
||||
component: lazyRouteComponent(() => import('./routes/index'), 'IndexPage'),
|
||||
})
|
||||
|
||||
// 配置路由 - 麦麦主程序配置
|
||||
const botConfigRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/config/bot',
|
||||
component: BotConfigPage,
|
||||
component: lazyRouteComponent(() => import('./routes/config/bot'), 'BotConfigPage'),
|
||||
})
|
||||
|
||||
// 配置路由 - 麦麦模型提供商配置
|
||||
const modelProviderConfigRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/config/modelProvider',
|
||||
component: ModelProviderConfigPage,
|
||||
component: lazyRouteComponent(
|
||||
() => import('./routes/config/modelProvider/index.tsx'),
|
||||
'ModelProviderConfigPage'
|
||||
),
|
||||
})
|
||||
|
||||
// 配置路由 - 麦麦模型配置
|
||||
const modelConfigRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/config/model',
|
||||
component: ModelConfigPage,
|
||||
component: lazyRouteComponent(() => import('./routes/config/model'), 'ModelConfigPage'),
|
||||
})
|
||||
|
||||
// 配置路由 - 麦麦适配器配置
|
||||
const adapterConfigRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/config/adapter',
|
||||
component: AdapterConfigPage,
|
||||
component: lazyRouteComponent(() => import('./routes/config/adapter'), 'AdapterConfigPage'),
|
||||
})
|
||||
|
||||
// 资源管理路由 - 表情包管理
|
||||
const emojiManagementRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/resource/emoji',
|
||||
component: EmojiManagementPage,
|
||||
component: lazyRouteComponent(
|
||||
() => import('./routes/resource/emoji/index.tsx'),
|
||||
'EmojiManagementPage'
|
||||
),
|
||||
})
|
||||
|
||||
// 资源管理路由 - 表达方式管理
|
||||
const expressionManagementRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/resource/expression',
|
||||
component: ExpressionManagementPage,
|
||||
component: lazyRouteComponent(
|
||||
() => import('./routes/resource/expression/index.tsx'),
|
||||
'ExpressionManagementPage'
|
||||
),
|
||||
})
|
||||
|
||||
// 资源管理路由 - 人物信息管理
|
||||
const personManagementRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/resource/person',
|
||||
component: PersonManagementPage,
|
||||
component: lazyRouteComponent(() => import('./routes/person'), 'PersonManagementPage'),
|
||||
})
|
||||
|
||||
// 资源管理路由 - 黑话管理
|
||||
const jargonManagementRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/resource/jargon',
|
||||
component: JargonManagementPage,
|
||||
component: lazyRouteComponent(
|
||||
() => import('./routes/resource/jargon/index.tsx'),
|
||||
'JargonManagementPage'
|
||||
),
|
||||
})
|
||||
|
||||
// 资源管理路由 - 知识库图谱可视化
|
||||
const knowledgeGraphRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/resource/knowledge-graph',
|
||||
component: KnowledgeGraphPage,
|
||||
component: lazyRouteComponent(
|
||||
() => import('./routes/resource/knowledge-graph/index.tsx'),
|
||||
'KnowledgeGraphPage'
|
||||
),
|
||||
})
|
||||
|
||||
// 资源管理路由 - 麦麦知识库管理
|
||||
const knowledgeBaseRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/resource/knowledge-base',
|
||||
component: KnowledgeBasePage,
|
||||
component: lazyRouteComponent(
|
||||
() => import('./routes/resource/knowledge-base'),
|
||||
'KnowledgeBasePage'
|
||||
),
|
||||
})
|
||||
|
||||
// 日志查看器路由
|
||||
const logsRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/logs',
|
||||
component: LogViewerPage,
|
||||
component: lazyRouteComponent(() => import('./routes/logs'), 'LogViewerPage'),
|
||||
})
|
||||
|
||||
// MaiSaka 聊天流监控路由
|
||||
const plannerMonitorRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/planner-monitor',
|
||||
component: PlannerMonitorPage,
|
||||
component: lazyRouteComponent(() => import('./routes/monitor/index.tsx'), 'PlannerMonitorPage'),
|
||||
})
|
||||
|
||||
// 本地聊天室路由
|
||||
const chatRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/chat',
|
||||
component: ChatPage,
|
||||
component: lazyRouteComponent(() => import('./routes/chat/index'), 'ChatPage'),
|
||||
})
|
||||
|
||||
// 插件市场路由
|
||||
const pluginsRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/plugins',
|
||||
component: PluginsPage,
|
||||
component: lazyRouteComponent(() => import('./routes/plugins/index'), 'PluginsPage'),
|
||||
})
|
||||
|
||||
// 插件详情路由
|
||||
const pluginDetailRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/plugin-detail',
|
||||
component: PluginDetailPage,
|
||||
component: lazyRouteComponent(() => import('./routes/plugin-detail'), 'PluginDetailPage'),
|
||||
})
|
||||
|
||||
// 模型分配预设市场路由
|
||||
const modelPresetsRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/model-presets',
|
||||
component: ModelPresetsPage,
|
||||
component: lazyRouteComponent(() => import('./routes/model-presets'), 'ModelPresetsPage'),
|
||||
})
|
||||
|
||||
// 插件配置路由
|
||||
const pluginConfigRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/plugin-config',
|
||||
component: PluginConfigPage,
|
||||
component: lazyRouteComponent(() => import('./routes/plugin-config'), 'PluginConfigPage'),
|
||||
})
|
||||
|
||||
// 插件镜像源配置路由
|
||||
const pluginMirrorsRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/plugin-mirrors',
|
||||
component: PluginMirrorsPage,
|
||||
component: lazyRouteComponent(() => import('./routes/plugin-mirrors'), 'PluginMirrorsPage'),
|
||||
})
|
||||
|
||||
// 设置页路由
|
||||
const settingsRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/settings',
|
||||
component: SettingsPage,
|
||||
component: lazyRouteComponent(() => import('./routes/settings/index.tsx'), 'SettingsPage'),
|
||||
})
|
||||
|
||||
// 配置模板市场路由
|
||||
const packMarketRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/config/pack-market',
|
||||
component: PackMarketPage,
|
||||
component: lazyRouteComponent(() => import('./routes/config/pack-market')),
|
||||
})
|
||||
|
||||
// 配置模板详情路由
|
||||
export const packDetailRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/config/pack-market/$packId',
|
||||
component: PackDetailPage,
|
||||
component: lazyRouteComponent(() => import('./routes/config/pack-detail')),
|
||||
})
|
||||
|
||||
// 问卷调查路由 - WebUI 反馈
|
||||
const webuiFeedbackSurveyRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/survey/webui-feedback',
|
||||
component: WebUIFeedbackSurveyPage,
|
||||
component: lazyRouteComponent(
|
||||
() => import('./routes/survey/webui-feedback'),
|
||||
'WebUIFeedbackSurveyPage'
|
||||
),
|
||||
})
|
||||
|
||||
// 问卷调查路由 - 麦麦体验反馈
|
||||
const maibotFeedbackSurveyRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/survey/maibot-feedback',
|
||||
component: MaiBotFeedbackSurveyPage,
|
||||
component: lazyRouteComponent(
|
||||
() => import('./routes/survey/maibot-feedback'),
|
||||
'MaiBotFeedbackSurveyPage'
|
||||
),
|
||||
})
|
||||
|
||||
// 404 路由
|
||||
@@ -294,7 +300,7 @@ function collectRoutePaths(node: RouteNode): string[] {
|
||||
export const registeredRoutePaths = new Set(collectRoutePaths(routeTree as RouteNode))
|
||||
|
||||
// 创建路由器
|
||||
export const router = createRouter({
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
defaultNotFoundComponent: NotFoundPage,
|
||||
defaultErrorComponent: ({ error }) => <RouteErrorBoundary error={error} />,
|
||||
|
||||
72
dashboard/src/routes/chat/ChatComposer.tsx
Normal file
72
dashboard/src/routes/chat/ChatComposer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
dashboard/src/routes/chat/ChatHeaderBar.tsx
Normal file
124
dashboard/src/routes/chat/ChatHeaderBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Bot, Plus, UserCircle2, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MessageSquare, Plus, UserCircle2, X } from 'lucide-react'
|
||||
|
||||
import type { ChatTab } from './types'
|
||||
|
||||
@@ -11,69 +13,74 @@ interface ChatTabBarProps {
|
||||
onAddVirtual: () => void
|
||||
}
|
||||
|
||||
export function ChatTabBar({
|
||||
tabs,
|
||||
activeTabId,
|
||||
onSwitch,
|
||||
onClose,
|
||||
onAddVirtual,
|
||||
}: ChatTabBarProps) {
|
||||
/**
|
||||
* 移动端横向会话切换条:在窄屏隐藏侧边栏时使用,保持与桌面端一致的视觉语言。
|
||||
*/
|
||||
export function ChatTabBar({ tabs, activeTabId, onSwitch, onClose, onAddVirtual }: ChatTabBarProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b bg-muted/30">
|
||||
<div className="max-w-4xl mx-auto px-2 sm:px-4">
|
||||
<div className="flex items-center gap-1 overflow-x-auto py-1.5 scrollbar-thin">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
<div className="bg-card/85 supports-backdrop-filter:bg-card/65 shrink-0 border-b backdrop-blur">
|
||||
<div className="scrollbar-thin flex items-center gap-1 overflow-x-auto px-3 py-2">
|
||||
{tabs.map((tab) => {
|
||||
const active = activeTabId === tab.id
|
||||
const Icon = tab.type === 'virtual' ? UserCircle2 : Bot
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm whitespace-nowrap transition-colors cursor-pointer",
|
||||
"hover:bg-muted",
|
||||
activeTabId === tab.id
|
||||
? "bg-background shadow-sm border"
|
||||
: "text-muted-foreground"
|
||||
'group flex shrink-0 items-center rounded-full border text-xs transition',
|
||||
active
|
||||
? 'bg-primary text-primary-foreground border-transparent shadow-sm'
|
||||
: 'bg-background/60 text-muted-foreground hover:text-foreground hover:bg-background border-transparent'
|
||||
)}
|
||||
type="button"
|
||||
onClick={() => onSwitch(tab.id)}
|
||||
>
|
||||
{tab.type === 'webui' ? (
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<UserCircle2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="max-w-[100px] truncate">{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' && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 rounded-full px-3 py-1.5"
|
||||
onClick={() => onSwitch(tab.id)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span className="max-w-32 truncate font-medium">{tab.label}</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)}
|
||||
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" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{/* 新建虚拟身份标签页按钮 */}
|
||||
<button
|
||||
onClick={onAddVirtual}
|
||||
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"
|
||||
title="新建虚拟身份对话"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('chat.sidebar.newVirtual')}
|
||||
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"
|
||||
onClick={onAddVirtual}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
265
dashboard/src/routes/chat/ChatWorkspaceSidebar.tsx
Normal file
265
dashboard/src/routes/chat/ChatWorkspaceSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
227
dashboard/src/routes/chat/MessageList.tsx
Normal file
227
dashboard/src/routes/chat/MessageList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,87 +1,106 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ChatMessage, MessageSegment } from './types'
|
||||
|
||||
// 渲染单个消息段
|
||||
export function RenderMessageSegment({ segment }: { segment: MessageSegment }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
switch (segment.type) {
|
||||
case 'text':
|
||||
return <span className="whitespace-pre-wrap">{String(segment.data)}</span>
|
||||
|
||||
|
||||
case 'image':
|
||||
case 'emoji':
|
||||
case 'emoji': {
|
||||
const mediaLabel = segment.type === 'emoji' ? t('chat.media.emoji') : t('chat.media.image')
|
||||
|
||||
return (
|
||||
<img
|
||||
src={String(segment.data)}
|
||||
alt={segment.type === 'emoji' ? '表情包' : '图片'}
|
||||
<img
|
||||
src={String(segment.data)}
|
||||
alt={mediaLabel}
|
||||
className={cn(
|
||||
"rounded-lg max-w-full",
|
||||
segment.type === 'emoji' ? "max-h-32" : "max-h-64"
|
||||
'max-w-full rounded-lg',
|
||||
segment.type === 'emoji' ? 'max-h-32' : 'max-h-64'
|
||||
)}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
// 图片加载失败时显示占位符
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
target.parentElement?.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
`<span class="text-muted-foreground text-xs">[${segment.type === 'emoji' ? '表情包' : '图片'}加载失败]</span>`
|
||||
)
|
||||
const fallback = document.createElement('span')
|
||||
fallback.className = 'text-muted-foreground text-xs'
|
||||
fallback.textContent = t('chat.media.loadFailed', { type: mediaLabel })
|
||||
target.parentElement?.appendChild(fallback)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
case 'voice':
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<audio
|
||||
controls
|
||||
src={String(segment.data)}
|
||||
className="max-w-[200px] h-8"
|
||||
>
|
||||
<track kind="captions" src="" label="无字幕" default />
|
||||
您的浏览器不支持音频播放
|
||||
<audio controls src={String(segment.data)} className="h-8 max-w-[200px]">
|
||||
<track kind="captions" src="" label={t('chat.media.noCaptions')} default />
|
||||
{t('chat.media.audioUnsupported')}
|
||||
</audio>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
case 'video':
|
||||
return (
|
||||
<video
|
||||
controls
|
||||
src={String(segment.data)}
|
||||
className="rounded-lg max-w-full max-h-64"
|
||||
>
|
||||
<track kind="captions" src="" label="无字幕" default />
|
||||
您的浏览器不支持视频播放
|
||||
<video controls src={String(segment.data)} className="max-h-64 max-w-full rounded-lg">
|
||||
<track kind="captions" src="" label={t('chat.media.noCaptions')} default />
|
||||
{t('chat.media.videoUnsupported')}
|
||||
</video>
|
||||
)
|
||||
|
||||
|
||||
case 'face':
|
||||
// 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':
|
||||
return <span className="text-muted-foreground">[音乐分享]</span>
|
||||
|
||||
return <span className="text-muted-foreground">{t('chat.media.music')}</span>
|
||||
|
||||
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':
|
||||
return <span className="text-muted-foreground text-xs">[回复消息]</span>
|
||||
|
||||
return <span className="text-muted-foreground text-xs">{t('chat.media.reply')}</span>
|
||||
|
||||
case 'forward':
|
||||
return <span className="text-muted-foreground">[转发消息]</span>
|
||||
|
||||
return <span className="text-muted-foreground">{t('chat.media.forward')}</span>
|
||||
|
||||
case 'unknown':
|
||||
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
|
||||
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) {
|
||||
return (
|
||||
@@ -92,7 +111,7 @@ export function RenderMessageContent({ message, isBot: _isBot }: { message: Chat
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 普通文本消息
|
||||
return <span className="whitespace-pre-wrap">{message.content}</span>
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
} from '@/components/ui/dialog'
|
||||
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 {
|
||||
Select,
|
||||
@@ -18,9 +18,10 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
} from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Globe, Loader2, Search, UserCircle2, Users } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { PersonInfo, PlatformInfo, VirtualIdentityConfig } from './types'
|
||||
|
||||
@@ -53,30 +54,33 @@ export function VirtualIdentityDialog({
|
||||
onSelectPerson,
|
||||
onCreateVirtualTab,
|
||||
}: VirtualIdentityDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<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>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserCircle2 className="h-5 w-5" />
|
||||
新建虚拟身份对话
|
||||
{t('chat.dialog.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择一个麦麦已认识的用户,以该用户的身份与麦麦对话。麦麦将使用她对该用户的记忆和认知来回应。
|
||||
</DialogDescription>
|
||||
<DialogDescription>{t('chat.dialog.description')}</DialogDescription>
|
||||
</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">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
选择平台
|
||||
{t('chat.dialog.platform')}
|
||||
</Label>
|
||||
<Select
|
||||
value={tempVirtualConfig.platform}
|
||||
onValueChange={(value) => {
|
||||
setTempVirtualConfig(prev => ({
|
||||
setTempVirtualConfig((prev) => ({
|
||||
...prev,
|
||||
platform: value,
|
||||
personId: '',
|
||||
@@ -86,12 +90,18 @@ export function VirtualIdentityDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger disabled={isLoadingPlatforms}>
|
||||
<SelectValue placeholder={isLoadingPlatforms ? "加载中..." : "选择平台"} />
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingPlatforms
|
||||
? t('chat.dialog.loading')
|
||||
: t('chat.dialog.platformPlaceholder')
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{platforms.map((p) => (
|
||||
<SelectItem key={p.platform} value={p.platform}>
|
||||
{p.platform} ({p.count} 人)
|
||||
{p.platform} {t('chat.dialog.personCount', { count: p.count })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -100,70 +110,76 @@ export function VirtualIdentityDialog({
|
||||
|
||||
{/* 用户搜索和选择 */}
|
||||
{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">
|
||||
<Users className="h-4 w-4" />
|
||||
选择用户
|
||||
{t('chat.dialog.user')}
|
||||
</Label>
|
||||
<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
|
||||
placeholder="搜索用户名..."
|
||||
placeholder={t('chat.dialog.searchUser')}
|
||||
value={personSearchQuery}
|
||||
onChange={(e) => setPersonSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="h-62.5 border rounded-md">
|
||||
<div className="p-2">
|
||||
<ScrollArea className="bg-background/40 h-62.5 rounded-lg border">
|
||||
<div className="p-1.5">
|
||||
{isLoadingPersons ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : persons.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Users className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">没有找到用户</p>
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-10">
|
||||
<Users className="mb-2 h-8 w-8 opacity-50" />
|
||||
<p className="text-sm">{t('chat.dialog.noUsers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{persons.map((person) => (
|
||||
<button
|
||||
key={person.person_id}
|
||||
onClick={() => onSelectPerson(person)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 p-2 rounded-md text-left transition-colors",
|
||||
tempVirtualConfig.personId === person.person_id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
<AvatarFallback className={cn(
|
||||
"text-xs",
|
||||
tempVirtualConfig.personId === person.person_id
|
||||
? "bg-primary-foreground/20"
|
||||
: "bg-muted"
|
||||
)}>
|
||||
{(person.nickname || person.person_name || '?').charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium truncate">
|
||||
{person.nickname || person.person_name}
|
||||
<div className="space-y-0.5">
|
||||
{persons.map((person) => {
|
||||
const selected = tempVirtualConfig.personId === person.person_id
|
||||
const display = person.nickname || person.person_name
|
||||
return (
|
||||
<button
|
||||
key={person.person_id}
|
||||
type="button"
|
||||
onClick={() => onSelectPerson(person)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md p-2 text-left transition-colors',
|
||||
selected
|
||||
? 'bg-primary/12 text-foreground'
|
||||
: 'hover:bg-muted/70'
|
||||
)}
|
||||
>
|
||||
<Avatar className="h-9 w-9 shrink-0 ring-1 ring-border/60">
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
'text-xs font-semibold',
|
||||
selected
|
||||
? 'bg-primary-gradient text-primary-foreground'
|
||||
: 'bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
{(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 className={cn(
|
||||
"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>
|
||||
))}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -174,32 +190,32 @@ export function VirtualIdentityDialog({
|
||||
{/* 虚拟群名配置 */}
|
||||
{tempVirtualConfig.personId && (
|
||||
<div className="space-y-2">
|
||||
<Label>虚拟群名(可选)</Label>
|
||||
<Label>{t('chat.dialog.groupName')}</Label>
|
||||
<Input
|
||||
placeholder="WebUI虚拟群聊"
|
||||
placeholder={t('chat.virtualGroupFallback')}
|
||||
value={tempVirtualConfig.groupName}
|
||||
onChange={(e) => setTempVirtualConfig(prev => ({
|
||||
...prev,
|
||||
groupName: e.target.value
|
||||
}))}
|
||||
onChange={(e) =>
|
||||
setTempVirtualConfig((prev) => ({
|
||||
...prev,
|
||||
groupName: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
麦麦会认为这是一个名为此名称的群聊
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">{t('chat.dialog.groupNameHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
{t('chat.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
data-dialog-action="confirm"
|
||||
onClick={onCreateVirtualTab}
|
||||
disabled={!tempVirtualConfig.platform || !tempVirtualConfig.personId}
|
||||
>
|
||||
创建对话
|
||||
{t('chat.dialog.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
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 { RestartOverlay } from '@/components/restart-overlay'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { ListFieldEditor } from '@/components/ListFieldEditor'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { CodeEditor } from '@/components'
|
||||
import { CodeEditor } from '@/components/CodeEditor'
|
||||
import { parse as parseToml } from 'smol-toml'
|
||||
import {
|
||||
Select,
|
||||
|
||||
@@ -37,8 +37,8 @@ import {
|
||||
type GitStatus,
|
||||
type MaimaiVersion,
|
||||
} from '@/lib/plugin-api'
|
||||
import { MarkdownRenderer } from '@/components/markdown-renderer'
|
||||
import { PluginStats } from '@/components/plugin-stats'
|
||||
import { MarkdownRenderer } from '@/components'
|
||||
import { recordPluginDownload } from '@/lib/plugin-stats'
|
||||
|
||||
// 分类名称映射
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { CodeEditor } from '@/components'
|
||||
import { CodeEditor } from '@/components/CodeEditor'
|
||||
import { MemoryDeleteDialog } from '@/components/memory/MemoryDeleteDialog'
|
||||
import { MemoryConfigEditor } from '@/components/memory/MemoryConfigEditor'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
|
||||
@@ -171,10 +171,14 @@ export function NodeDetailDialog({
|
||||
</Button>
|
||||
{onDeleteEntity ? (
|
||||
<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">
|
||||
<Checkbox checked={includeParagraphs} onCheckedChange={(checked) => setIncludeParagraphs(Boolean(checked))} />
|
||||
删除该实体相关证据段落
|
||||
</label>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={includeParagraphs}
|
||||
onCheckedChange={(checked) => setIncludeParagraphs(Boolean(checked))}
|
||||
aria-label="删除该实体相关证据段落"
|
||||
/>
|
||||
<span>删除该实体相关证据段落</span>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => onDeleteEntity({ includeParagraphs })}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除实体
|
||||
@@ -280,10 +284,14 @@ export function EdgeDetailDialog({
|
||||
</Button>
|
||||
{onDeleteEdgeGroup ? (
|
||||
<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">
|
||||
<Checkbox checked={includeParagraphs} onCheckedChange={(checked) => setIncludeParagraphs(Boolean(checked))} />
|
||||
同时删除支撑段落
|
||||
</label>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={includeParagraphs}
|
||||
onCheckedChange={(checked) => setIncludeParagraphs(Boolean(checked))}
|
||||
aria-label="同时删除支撑段落"
|
||||
/>
|
||||
<span>同时删除支撑段落</span>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => onDeleteEdgeGroup({ includeParagraphs })}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除此关系组
|
||||
@@ -371,10 +379,14 @@ export function RelationDetailDialog({
|
||||
|
||||
{onDeleteRelation ? (
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Checkbox checked={includeParagraphs} onCheckedChange={(checked) => setIncludeParagraphs(Boolean(checked))} />
|
||||
同时删除支撑该关系的段落
|
||||
</label>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={includeParagraphs}
|
||||
onCheckedChange={(checked) => setIncludeParagraphs(Boolean(checked))}
|
||||
aria-label="同时删除支撑该关系的段落"
|
||||
/>
|
||||
<span>同时删除支撑该关系的段落</span>
|
||||
</div>
|
||||
<Button className="mt-3" variant="outline" onClick={() => onDeleteRelation(relation, includeParagraphs)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除这条关系
|
||||
|
||||
@@ -313,6 +313,7 @@ interface PersonalityFormProps {
|
||||
|
||||
export function PersonalityForm({ config, onChange }: PersonalityFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const multipleReplyStyleText = config.multiple_reply_style.join('\n')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -345,48 +346,49 @@ export function PersonalityForm({ config, onChange }: PersonalityFormProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="interest">{t('setupPage.forms.personality.interest.label')}</Label>
|
||||
<Textarea
|
||||
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 htmlFor="multiple_reply_style">
|
||||
{t('setupPage.forms.personality.multipleReplyStyle.label')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="private_plan_style"
|
||||
placeholder={t('setupPage.forms.personality.privatePlanStyle.placeholder')}
|
||||
value={config.private_plan_style}
|
||||
onChange={(e) => onChange({ ...config, private_plan_style: e.target.value })}
|
||||
rows={3}
|
||||
id="multiple_reply_style"
|
||||
placeholder={t('setupPage.forms.personality.multipleReplyStyle.placeholder')}
|
||||
value={multipleReplyStyleText}
|
||||
onChange={(e) =>
|
||||
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">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -405,23 +407,17 @@ export function EmojiForm({ config, onChange }: EmojiFormProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<Label htmlFor="emoji_send_num">{t('setupPage.forms.emoji.emojiSendNum.label')}</Label>
|
||||
<Input
|
||||
id="emoji_chance"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={config.emoji_chance}
|
||||
onChange={(e) => onChange({ ...config, emoji_chance: Number(e.target.value) })}
|
||||
id="emoji_send_num"
|
||||
type="number"
|
||||
min="1"
|
||||
max="64"
|
||||
value={config.emoji_send_num}
|
||||
onChange={(e) => onChange({ ...config, emoji_send_num: Number(e.target.value) })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('setupPage.forms.emoji.emojiChance.description')}
|
||||
{t('setupPage.forms.emoji.emojiSendNum.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -532,22 +528,6 @@ export function OtherBasicForm({ config, onChange }: OtherBasicFormProps) {
|
||||
|
||||
return (
|
||||
<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="space-y-1">
|
||||
<Label htmlFor="all_global">{t('setupPage.forms.other.allGlobal.label')}</Label>
|
||||
|
||||
@@ -51,9 +51,8 @@ export async function loadPersonalityConfig(): Promise<PersonalityConfig> {
|
||||
return {
|
||||
personality: personalityConfig.personality || '',
|
||||
reply_style: personalityConfig.reply_style || '',
|
||||
interest: personalityConfig.interest || '',
|
||||
plan_style: personalityConfig.plan_style || '',
|
||||
private_plan_style: personalityConfig.private_plan_style || '',
|
||||
multiple_reply_style: personalityConfig.multiple_reply_style || [],
|
||||
multiple_probability: personalityConfig.multiple_probability ?? 0.2,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +70,8 @@ export async function loadEmojiConfig(): Promise<EmojiConfig> {
|
||||
const emojiConfig = (data.config.emoji || {}) as Partial<EmojiConfig>
|
||||
|
||||
return {
|
||||
emoji_chance: emojiConfig.emoji_chance ?? 0.4,
|
||||
max_reg_num: emojiConfig.max_reg_num ?? 40,
|
||||
emoji_send_num: emojiConfig.emoji_send_num ?? 25,
|
||||
max_reg_num: emojiConfig.max_reg_num ?? 64,
|
||||
do_replace: emojiConfig.do_replace ?? true,
|
||||
check_interval: emojiConfig.check_interval ?? 10,
|
||||
steal_emoji: emojiConfig.steal_emoji ?? true,
|
||||
@@ -90,18 +89,15 @@ export async function loadOtherBasicConfig(): Promise<OtherBasicConfig> {
|
||||
|
||||
const result = await parseResponse<{
|
||||
config: {
|
||||
tool?: { enable_tool?: boolean }
|
||||
expression?: { all_global_jargon?: boolean }
|
||||
}
|
||||
}>(response)
|
||||
const data = throwIfError(result)
|
||||
const config = data.config
|
||||
|
||||
const toolConfig = config.tool || {}
|
||||
const expressionConfig = config.expression || {}
|
||||
|
||||
return {
|
||||
enable_tool: toolConfig.enable_tool ?? true,
|
||||
all_global: expressionConfig.all_global_jargon ?? true,
|
||||
}
|
||||
}
|
||||
@@ -169,38 +165,16 @@ export async function saveEmojiConfig(config: EmojiConfig) {
|
||||
return throwIfError(result)
|
||||
}
|
||||
|
||||
// 保存其他基础配置(工具、情绪、黑话)
|
||||
// 保存其他基础配置(黑话)
|
||||
export async function saveOtherBasicConfig(config: OtherBasicConfig) {
|
||||
// 需要分别保存到不同的section
|
||||
const promises = []
|
||||
const response = await fetchWithAuth('/api/webui/config/bot/section/expression', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ all_global_jargon: config.all_global }),
|
||||
})
|
||||
|
||||
// 保存tool配置
|
||||
promises.push(
|
||||
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 }
|
||||
const result = await parseResponse(response)
|
||||
return throwIfError(result)
|
||||
}
|
||||
|
||||
// 保存硅基流动API配置
|
||||
|
||||
@@ -95,13 +95,17 @@ function SetupPageContent() {
|
||||
const createDefaultPersonalityConfig = (): PersonalityConfig => ({
|
||||
personality: t('setupPage.defaults.personality.personality'),
|
||||
reply_style: t('setupPage.defaults.personality.replyStyle'),
|
||||
interest: t('setupPage.defaults.personality.interest'),
|
||||
plan_style: t('setupPage.defaults.personality.planStyle'),
|
||||
private_plan_style: t('setupPage.defaults.personality.privatePlanStyle'),
|
||||
multiple_reply_style: [
|
||||
t('setupPage.defaults.personality.multipleReplyStyles.plain'),
|
||||
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 => ({
|
||||
emoji_chance: 0.4,
|
||||
max_reg_num: 40,
|
||||
emoji_send_num: 25,
|
||||
max_reg_num: 64,
|
||||
do_replace: true,
|
||||
check_interval: 10,
|
||||
steal_emoji: true,
|
||||
@@ -132,7 +136,6 @@ function SetupPageContent() {
|
||||
|
||||
// 步骤4:其他基础配置
|
||||
const [otherBasic, setOtherBasic] = useState<OtherBasicConfig>({
|
||||
enable_tool: true,
|
||||
all_global: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -20,14 +20,13 @@ export interface BotBasicConfig {
|
||||
export interface PersonalityConfig {
|
||||
personality: string
|
||||
reply_style: string
|
||||
interest: string
|
||||
plan_style: string
|
||||
private_plan_style: string
|
||||
multiple_reply_style: string[]
|
||||
multiple_probability: number
|
||||
}
|
||||
|
||||
// 步骤3:表情包配置
|
||||
export interface EmojiConfig {
|
||||
emoji_chance: number
|
||||
emoji_send_num: number
|
||||
max_reg_num: number
|
||||
do_replace: boolean
|
||||
check_interval: number
|
||||
@@ -38,7 +37,6 @@ export interface EmojiConfig {
|
||||
|
||||
// 步骤4:其他基础配置
|
||||
export interface OtherBasicConfig {
|
||||
enable_tool: boolean
|
||||
all_global: boolean // 全局黑话模式(expression.all_global_jargon)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user