refactor(web): router-based navigation, light theme, form config
- Replace Zustand activeView with React Router (NavLink + Routes) - White/light modern theme with indigo accents - Sidebar with Chinese labels under icons - ConfigForm with individual form fields (no JSON textareas) - Account switching with context injection into chat - Fix duplicate conversation creation with useRef guard - Asset gallery: smaller 6-column grid with date labels - All components updated to light color scheme Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>美图 Agent</title>
|
<title>美图 Agent</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-zinc-950 text-zinc-50 antialiased">
|
<body class="bg-white text-zinc-900 antialiased">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
89
web/client/package-lock.json
generated
89
web/client/package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
|
"react-router-dom": "^7.15.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"@types/markdown-it": "^14.1.0",
|
"@types/markdown-it": "^14.1.0",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
@@ -1212,6 +1214,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/history": {
|
||||||
|
"version": "4.7.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
|
||||||
|
"integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/linkify-it": {
|
"node_modules/@types/linkify-it": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
@@ -1265,6 +1274,29 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react-router": {
|
||||||
|
"version": "5.1.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz",
|
||||||
|
"integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/history": "^4.7.11",
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/react-router-dom": {
|
||||||
|
"version": "5.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz",
|
||||||
|
"integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/history": "^4.7.11",
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-router": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -1537,6 +1569,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -2356,6 +2401,44 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
|
||||||
|
"integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.0.tgz",
|
||||||
|
"integrity": "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.15.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -2500,6 +2583,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@@ -8,24 +8,26 @@
|
|||||||
"build": "vite build"
|
"build": "vite build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"lucide-react": "^0.460.0",
|
||||||
|
"markdown-it": "^14.1.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"zustand": "^5.0.0",
|
"react-router-dom": "^7.15.0",
|
||||||
"lucide-react": "^0.460.0",
|
|
||||||
"clsx": "^2.1.0",
|
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"zustand": "^5.0.0"
|
||||||
"markdown-it": "^14.1.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/markdown-it": "^14.1.0",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/markdown-it": "^14.1.0",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"vite": "^5.4.0",
|
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"postcss": "^8.4.0",
|
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"typescript": "^5.6.0"
|
"postcss": "^8.4.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vite": "^5.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { AppLayout } from '@/components/layout/AppLayout';
|
import { AppLayout } from '@/components/layout/AppLayout';
|
||||||
import { ChatView } from '@/components/chat/ChatView';
|
import { ChatView } from '@/components/chat/ChatView';
|
||||||
import { AccountList } from '@/components/accounts/AccountList';
|
import { AccountList } from '@/components/accounts/AccountList';
|
||||||
import { AssetGallery } from '@/components/assets/AssetGallery';
|
import { AssetGallery } from '@/components/assets/AssetGallery';
|
||||||
import { ConfigForm } from '@/components/config/ConfigForm';
|
import { ConfigForm } from '@/components/config/ConfigForm';
|
||||||
import { useAppStore } from '@/store';
|
|
||||||
|
|
||||||
function MainContent() {
|
|
||||||
const view = useAppStore((s) => s.activeView);
|
|
||||||
switch (view) {
|
|
||||||
case 'chat': return <ChatView />;
|
|
||||||
case 'accounts': return <AccountList />;
|
|
||||||
case 'assets': return <AssetGallery />;
|
|
||||||
case 'config': return <ConfigForm />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<MainContent />
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/chat" replace />} />
|
||||||
|
<Route path="/chat" element={<ChatView />} />
|
||||||
|
<Route path="/chat/:conversationId" element={<ChatView />} />
|
||||||
|
<Route path="/accounts" element={<AccountList />} />
|
||||||
|
<Route path="/accounts/:accountId" element={<AccountList />} />
|
||||||
|
<Route path="/assets" element={<AssetGallery />} />
|
||||||
|
<Route path="/settings" element={<ConfigForm />} />
|
||||||
|
</Routes>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function AccountForm({ account, onSave, onDelete, onCancel }: Props) {
|
|||||||
onChange={(e) => handleChange('id', e.target.value)}
|
onChange={(e) => handleChange('id', e.target.value)}
|
||||||
disabled={!!account}
|
disabled={!!account}
|
||||||
placeholder="my-account"
|
placeholder="my-account"
|
||||||
className="mt-1 bg-zinc-900 border-zinc-800"
|
className="mt-1 bg-zinc-50 border-zinc-200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export function AccountForm({ account, onSave, onDelete, onCancel }: Props) {
|
|||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => handleChange('name', e.target.value)}
|
onChange={(e) => handleChange('name', e.target.value)}
|
||||||
placeholder="账户名称"
|
placeholder="账户名称"
|
||||||
className="mt-1 bg-zinc-900 border-zinc-800"
|
className="mt-1 bg-zinc-50 border-zinc-200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ export function AccountForm({ account, onSave, onDelete, onCancel }: Props) {
|
|||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={(e) => handleChange('description', e.target.value)}
|
onChange={(e) => handleChange('description', e.target.value)}
|
||||||
placeholder="简短描述..."
|
placeholder="简短描述..."
|
||||||
className="mt-1 bg-zinc-900 border-zinc-800"
|
className="mt-1 bg-zinc-50 border-zinc-200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ export function AccountForm({ account, onSave, onDelete, onCancel }: Props) {
|
|||||||
value={form.ttsVoice}
|
value={form.ttsVoice}
|
||||||
onChange={(e) => handleChange('ttsVoice', e.target.value)}
|
onChange={(e) => handleChange('ttsVoice', e.target.value)}
|
||||||
placeholder="cosyvoice-xxx"
|
placeholder="cosyvoice-xxx"
|
||||||
className="mt-1 bg-zinc-900 border-zinc-800 font-mono text-xs"
|
className="mt-1 bg-zinc-50 border-zinc-200 font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useAccounts } from '@/hooks/useAccounts';
|
import { useAccounts } from '@/hooks/useAccounts';
|
||||||
import { AccountForm } from './AccountForm';
|
import { AccountForm } from './AccountForm';
|
||||||
import { PromptEditor } from '@/components/prompts/PromptEditor';
|
import { PromptEditor } from '@/components/prompts/PromptEditor';
|
||||||
@@ -8,43 +9,50 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|||||||
import type { Account } from '@/types';
|
import type { Account } from '@/types';
|
||||||
|
|
||||||
export function AccountList() {
|
export function AccountList() {
|
||||||
|
const { accountId } = useParams<{ accountId?: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { accounts, create, update, remove } = useAccounts();
|
const { accounts, create, update, remove } = useAccounts();
|
||||||
const [editing, setEditing] = useState<Account | null>(null);
|
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [subTab, setSubTab] = useState<'info' | 'prompts'>('info');
|
const [subTab, setSubTab] = useState<'info' | 'prompts'>('info');
|
||||||
|
const editing = accounts.find((a) => a.id === accountId) || null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
<ScrollArea className="w-60 border-r border-zinc-800 p-3">
|
<ScrollArea className="w-56 border-r border-zinc-200 bg-zinc-50 p-3">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-sm font-medium text-zinc-300">账户列表</h2>
|
<h2 className="text-sm font-semibold text-zinc-700">账户列表</h2>
|
||||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => setCreating(true)}>
|
<Button size="icon" variant="ghost" className="h-7 w-7 text-zinc-500 hover:text-zinc-700" onClick={() => setCreating(true)}>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
<button
|
<button
|
||||||
key={a.id}
|
key={a.id}
|
||||||
onClick={() => { setEditing(a); setSubTab('info'); }}
|
onClick={() => navigate(`/accounts/${a.id}`)}
|
||||||
className="w-full text-left px-3 py-2 rounded-md text-sm hover:bg-zinc-800/50 text-zinc-400 hover:text-zinc-200 transition-colors"
|
className={`block w-full text-left px-3 py-2 rounded-md text-sm transition-colors mb-0.5
|
||||||
|
${a.id === accountId
|
||||||
|
? 'bg-indigo-50 text-indigo-700 font-medium'
|
||||||
|
: 'text-zinc-600 hover:bg-white hover:text-zinc-900'}`}
|
||||||
>
|
>
|
||||||
<div className="font-medium truncate">{a.name}</div>
|
<div className="font-medium truncate">{a.name}</div>
|
||||||
<div className="text-xs text-zinc-600">{a.imageModel} · {a.defaultFormat}</div>
|
<div className="text-xs opacity-60">{a.imageModel} · {a.defaultFormat}</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col bg-white">
|
||||||
<div className="flex gap-0 border-b border-zinc-800 px-6">
|
<div className="flex gap-0 border-b border-zinc-200 px-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSubTab('info')}
|
onClick={() => setSubTab('info')}
|
||||||
className={`px-3 py-2 text-sm ${subTab === 'info' ? 'border-b-2 border-white text-white' : 'text-zinc-500'}`}
|
className={`px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px
|
||||||
|
${subTab === 'info' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'}`}
|
||||||
>
|
>
|
||||||
账户设置
|
账户设置
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSubTab('prompts')}
|
onClick={() => setSubTab('prompts')}
|
||||||
className={`px-3 py-2 text-sm ${subTab === 'prompts' ? 'border-b-2 border-white text-white' : 'text-zinc-500'}`}
|
className={`px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px
|
||||||
|
${subTab === 'prompts' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'}`}
|
||||||
>
|
>
|
||||||
提示词模板
|
提示词模板
|
||||||
</button>
|
</button>
|
||||||
@@ -59,19 +67,19 @@ export function AccountList() {
|
|||||||
if (creating) {
|
if (creating) {
|
||||||
create(data).then(() => setCreating(false));
|
create(data).then(() => setCreating(false));
|
||||||
} else {
|
} else {
|
||||||
update(editing!.id, data).then(() => setEditing(null));
|
update(editing!.id, data).then(() => {});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDelete={editing ? () => {
|
onDelete={editing ? () => {
|
||||||
if (confirm(`确定删除账户「${editing.name}」?`)) {
|
if (confirm(`确定删除账户「${editing.name}」?`)) {
|
||||||
remove(editing.id).then(() => setEditing(null));
|
remove(editing.id).then(() => navigate('/accounts'));
|
||||||
}
|
}
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onCancel={() => { setEditing(null); setCreating(false); }}
|
onCancel={() => { setCreating(false); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full text-zinc-600 text-sm">
|
<div className="flex items-center justify-center h-full text-zinc-400 text-sm">
|
||||||
选择一个账户或创建新账户
|
选择一个账户或创建新账户
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ export function AssetGallery() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-800">
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-200 bg-white">
|
||||||
<select
|
<select
|
||||||
value={accountFilter}
|
value={accountFilter}
|
||||||
onChange={(e) => setAccountFilter(e.target.value)}
|
onChange={(e) => setAccountFilter(e.target.value)}
|
||||||
className="h-8 rounded-md border border-zinc-800 bg-zinc-900 px-2 text-xs"
|
className="h-8 rounded-md border border-zinc-200 bg-zinc-50 px-2 text-xs"
|
||||||
>
|
>
|
||||||
<option value="">全部账号</option>
|
<option value="">全部账号</option>
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
@@ -40,12 +40,13 @@ export function AssetGallery() {
|
|||||||
<select
|
<select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
onChange={(e) => setTypeFilter(e.target.value)}
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
className="h-8 rounded-md border border-zinc-800 bg-zinc-900 px-2 text-xs"
|
className="h-8 rounded-md border border-zinc-200 bg-zinc-50 px-2 text-xs"
|
||||||
>
|
>
|
||||||
<option value="">全部类型</option>
|
<option value="">全部类型</option>
|
||||||
<option value="image">图片</option>
|
<option value="image">图片</option>
|
||||||
<option value="video">视频</option>
|
<option value="video">视频</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div className="flex-1" />
|
||||||
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={handleScan}>
|
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={handleScan}>
|
||||||
<RefreshCw size={12} className="mr-1" />
|
<RefreshCw size={12} className="mr-1" />
|
||||||
扫描
|
扫描
|
||||||
@@ -54,15 +55,18 @@ export function AssetGallery() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-zinc-500 text-sm text-center mt-8">加载中...</p>
|
<p className="text-zinc-400 text-sm text-center mt-8">加载中...</p>
|
||||||
) : assets.length === 0 ? (
|
) : assets.length === 0 ? (
|
||||||
<p className="text-zinc-600 text-sm text-center mt-8">暂无资产,点击"扫描"导入</p>
|
<div className="flex flex-col items-center justify-center mt-16 text-zinc-400">
|
||||||
|
<p className="text-sm">暂无资产</p>
|
||||||
|
<p className="text-xs mt-1">点击"扫描"从 output 目录导入</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-6 gap-2">
|
||||||
{assets.map((asset) => (
|
{assets.map((asset) => (
|
||||||
<div
|
<div
|
||||||
key={asset.id}
|
key={asset.id}
|
||||||
className="group relative aspect-[9/16] bg-zinc-900 rounded-lg overflow-hidden cursor-pointer border border-zinc-800 hover:border-zinc-600 transition-colors"
|
className="group relative aspect-square bg-zinc-100 rounded-lg overflow-hidden cursor-pointer border border-zinc-200 hover:border-indigo-300 hover:shadow-sm transition-all"
|
||||||
onClick={() => setPreviewAsset(asset)}
|
onClick={() => setPreviewAsset(asset)}
|
||||||
>
|
>
|
||||||
{asset.type === 'image' ? (
|
{asset.type === 'image' ? (
|
||||||
@@ -81,10 +85,20 @@ export function AssetGallery() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); remove(asset.id); }}
|
onClick={(e) => { e.stopPropagation(); remove(asset.id); }}
|
||||||
className="absolute top-1 right-1 p-1 rounded bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="absolute top-1 right-1 p-1 rounded bg-white/80 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
|
||||||
>
|
>
|
||||||
<Trash2 size={12} className="text-red-400" />
|
<Trash2 size={12} className="text-red-500" />
|
||||||
</button>
|
</button>
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/40 to-transparent p-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[9px] text-white font-medium">
|
||||||
|
{asset.type === 'image' ? '🖼' : '🎬'} #{asset.shot_index || '?'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] text-white/70">
|
||||||
|
{asset.created_at ? new Date(asset.created_at).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ export function ChatInput({ onSend, disabled }: { onSend: (content: string) => v
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 border-t border-zinc-800">
|
<div className="p-4 border-t border-zinc-200 bg-white">
|
||||||
<div className="flex items-end gap-2 bg-zinc-900 rounded-lg border border-zinc-800 px-3 py-2">
|
<div className="flex items-end gap-2 bg-zinc-50 rounded-xl border border-zinc-200 px-4 py-2.5 focus-within:border-indigo-300 transition-colors">
|
||||||
<textarea
|
<textarea
|
||||||
ref={ref}
|
ref={ref}
|
||||||
value={input}
|
value={input}
|
||||||
@@ -30,11 +30,11 @@ export function ChatInput({ onSend, disabled }: { onSend: (content: string) => v
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
rows={1}
|
rows={1}
|
||||||
placeholder={disabled ? '等待回复中...' : '输入指令...'}
|
placeholder={disabled ? '等待回复中...' : '输入指令...'}
|
||||||
className="flex-1 bg-transparent text-sm resize-none outline-none placeholder:text-zinc-600"
|
className="flex-1 bg-transparent text-sm resize-none outline-none placeholder:text-zinc-400 text-zinc-700"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={handleSend} disabled={disabled}>
|
<Button size="icon" variant="ghost" className="h-8 w-8 text-zinc-400 hover:text-indigo-600" onClick={handleSend} disabled={disabled}>
|
||||||
<Send size={14} />
|
<Send size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ export function ChatMessage({ message }: { message: Message }) {
|
|||||||
<div className={cn('mb-4 flex', isUser ? 'justify-end' : 'justify-start')}>
|
<div className={cn('mb-4 flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-w-[80%] rounded-lg px-4 py-2.5',
|
'max-w-[80%] rounded-xl px-4 py-2.5',
|
||||||
isUser
|
isUser
|
||||||
? 'bg-zinc-800 text-zinc-100'
|
? 'bg-indigo-600 text-white'
|
||||||
: 'bg-zinc-900 text-zinc-300 border border-zinc-800'
|
: 'bg-white text-zinc-700 border border-zinc-200 shadow-sm'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<span className="text-zinc-600 italic text-xs">...</span>
|
<span className="text-zinc-400 italic text-xs">...</span>
|
||||||
) : isUser ? (
|
) : isUser ? (
|
||||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
|
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useAppStore } from '@/store';
|
import { useAppStore } from '@/store';
|
||||||
import { useChat } from '@/hooks/useChat';
|
import { useChat } from '@/hooks/useChat';
|
||||||
import { ChatMessage } from './ChatMessage';
|
import { ChatMessage } from './ChatMessage';
|
||||||
import { ChatInput } from './ChatInput';
|
import { ChatInput } from './ChatInput';
|
||||||
import { PipelineProgress } from './PipelineProgress';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { RefreshCw, Loader2 } from 'lucide-react';
|
import { RefreshCw, Loader2 } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { Account } from '@/types';
|
||||||
|
|
||||||
export function ChatView() {
|
export function ChatView() {
|
||||||
const { activeConversationId, setConversations, selectedAccountId } = useAppStore();
|
const { conversationId } = useParams<{ conversationId?: string }>();
|
||||||
const { messages, connected, thinking, toolStatus, send, createConversation } = useChat(activeConversationId);
|
const navigate = useNavigate();
|
||||||
|
const { setConversations, selectedAccountId, setSelectedAccountId } = useAppStore();
|
||||||
|
const { messages, connected, thinking, toolStatus, send, createConversation } = useChat(conversationId || null);
|
||||||
const [manifestPath, setManifestPath] = useState<string | null>(null);
|
const [manifestPath, setManifestPath] = useState<string | null>(null);
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||||
|
const creatingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.listAccounts().then(setAccounts).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/pipeline/conversations')
|
fetch('/api/pipeline/conversations')
|
||||||
@@ -30,14 +40,34 @@ export function ChatView() {
|
|||||||
}
|
}
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const handleNewConversation = () => {
|
// Inject account context when account is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedAccountId && conversationId && connected && messages.length === 0) {
|
||||||
|
const account = accounts.find((a) => a.id === selectedAccountId);
|
||||||
|
if (account) {
|
||||||
|
const ctx = `已选择账号「${account.name}」:${account.description || ''}。生图:${account.imageModel} 视频:${account.videoModel} 画幅:${account.defaultFormat}`;
|
||||||
|
send(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedAccountId, conversationId, connected]);
|
||||||
|
|
||||||
|
const handleNewConversation = useCallback(() => {
|
||||||
|
if (creatingRef.current) return;
|
||||||
|
creatingRef.current = true;
|
||||||
|
|
||||||
createConversation('新对话', selectedAccountId || undefined);
|
createConversation('新对话', selectedAccountId || undefined);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fetch('/api/pipeline/conversations')
|
fetch('/api/pipeline/conversations')
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(setConversations);
|
.then((list) => {
|
||||||
}, 300);
|
setConversations(list);
|
||||||
};
|
if (list.length > 0) {
|
||||||
|
navigate(`/chat/${list[0].id}`);
|
||||||
|
}
|
||||||
|
creatingRef.current = false;
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
}, [createConversation, selectedAccountId, setConversations, navigate]);
|
||||||
|
|
||||||
const handleResume = async () => {
|
const handleResume = async () => {
|
||||||
if (!manifestPath) return;
|
if (!manifestPath) return;
|
||||||
@@ -52,26 +82,57 @@ export function ChatView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!activeConversationId) {
|
if (!conversationId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-zinc-500">
|
<div className="flex-1 flex flex-col items-center justify-center gap-4">
|
||||||
<p>选择对话或开始新对话</p>
|
<div className="text-4xl mb-2">💬</div>
|
||||||
<button
|
<h2 className="text-lg font-semibold text-zinc-800">开始新对话</h2>
|
||||||
onClick={handleNewConversation}
|
<p className="text-sm text-zinc-500">选择左侧对话或创建新的创作会话</p>
|
||||||
className="px-4 py-2 rounded-md bg-zinc-800 text-sm hover:bg-zinc-700 transition-colors"
|
<div className="flex gap-2 mt-2">
|
||||||
>
|
{accounts.length > 0 && (
|
||||||
开始新对话
|
<select
|
||||||
</button>
|
value={selectedAccountId || ''}
|
||||||
|
onChange={(e) => setSelectedAccountId(e.target.value || null)}
|
||||||
|
className="h-9 rounded-md border border-zinc-200 bg-white px-3 text-sm text-zinc-700"
|
||||||
|
>
|
||||||
|
<option value="">不指定账号</option>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>{a.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleNewConversation}
|
||||||
|
disabled={creatingRef.current}
|
||||||
|
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{creatingRef.current ? '创建中...' : '开始对话'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col bg-white">
|
||||||
<div className="px-4 py-2 border-b border-zinc-800 flex items-center justify-between">
|
<div className="px-4 py-2 border-b border-zinc-200 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs text-zinc-500">{connected ? '已连接' : '连接中...'}</span>
|
<div className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500' : 'bg-red-400'}`} />
|
||||||
|
<span className="text-xs text-zinc-400">{connected ? '在线' : '连接中'}</span>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={selectedAccountId || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedAccountId(e.target.value || null);
|
||||||
|
}}
|
||||||
|
className="h-7 rounded border border-zinc-200 bg-zinc-50 px-2 text-xs text-zinc-600"
|
||||||
|
>
|
||||||
|
<option value="">切换账号</option>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>{a.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{manifestPath && (
|
{manifestPath && (
|
||||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleResume}>
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleResume}>
|
||||||
@@ -87,7 +148,7 @@ export function ChatView() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{thinking && (
|
{thinking && (
|
||||||
<div className="flex items-center gap-2 text-zinc-500 text-sm py-2">
|
<div className="flex items-center gap-2 text-zinc-400 text-sm py-2">
|
||||||
<Loader2 size={14} className="animate-spin" />
|
<Loader2 size={14} className="animate-spin" />
|
||||||
{toolStatus || '思考中...'}
|
{toolStatus || '思考中...'}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,56 +1,189 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
export function ConfigForm() {
|
export function ConfigForm() {
|
||||||
const [configs, setConfigs] = useState<Record<string, string>>({});
|
const [form, setForm] = useState({
|
||||||
|
model: '',
|
||||||
|
baseUrl: '',
|
||||||
|
authToken: '',
|
||||||
|
defaultImageModel: '',
|
||||||
|
defaultVideoModel: '',
|
||||||
|
defaultFormat: '',
|
||||||
|
ossEndpoint: '',
|
||||||
|
ossBucket: '',
|
||||||
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getConfigs().then((list) => {
|
api.getConfigs().then((list) => {
|
||||||
const map: Record<string, string> = {};
|
const next = { ...form };
|
||||||
list.forEach((c) => { map[c.key] = JSON.stringify(c.value, null, 2); });
|
for (const item of list) {
|
||||||
setConfigs(map);
|
try {
|
||||||
|
const v = item.value as Record<string, string>;
|
||||||
|
if (item.key === 'api_keys') {
|
||||||
|
if (v.ANTHROPIC_MODEL) next.model = v.ANTHROPIC_MODEL;
|
||||||
|
if (v.ANTHROPIC_BASE_URL) next.baseUrl = v.ANTHROPIC_BASE_URL;
|
||||||
|
if (v.ANTHROPIC_AUTH_TOKEN) next.authToken = v.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
}
|
||||||
|
if (item.key === 'defaults') {
|
||||||
|
if (v.imageModel) next.defaultImageModel = v.imageModel;
|
||||||
|
if (v.videoModel) next.defaultVideoModel = v.videoModel;
|
||||||
|
if (v.format) next.defaultFormat = v.format;
|
||||||
|
}
|
||||||
|
if (item.key === 'endpoints') {
|
||||||
|
if (v.ossEndpoint) next.ossEndpoint = v.ossEndpoint;
|
||||||
|
if (v.ossBucket) next.ossBucket = v.ossBucket;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
setForm(next);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = async (key: string, raw: string) => {
|
const handleChange = (key: string, value: string) => {
|
||||||
|
setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.saveConfig(key, JSON.parse(raw));
|
await api.saveConfig('api_keys', {
|
||||||
} catch { alert('Invalid JSON'); }
|
ANTHROPIC_MODEL: form.model,
|
||||||
|
ANTHROPIC_BASE_URL: form.baseUrl,
|
||||||
|
ANTHROPIC_AUTH_TOKEN: form.authToken,
|
||||||
|
});
|
||||||
|
await api.saveConfig('defaults', {
|
||||||
|
imageModel: form.defaultImageModel,
|
||||||
|
videoModel: form.defaultVideoModel,
|
||||||
|
format: form.defaultFormat,
|
||||||
|
});
|
||||||
|
await api.saveConfig('endpoints', {
|
||||||
|
ossEndpoint: form.ossEndpoint,
|
||||||
|
ossBucket: form.ossBucket,
|
||||||
|
});
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
} catch {}
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const configKeys = [
|
|
||||||
{ key: 'api_keys', label: 'API 密钥' },
|
|
||||||
{ key: 'defaults', label: '默认参数' },
|
|
||||||
{ key: 'endpoints', label: '服务端点' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg p-6 space-y-6">
|
<div className="max-w-xl mx-auto p-6 space-y-8 overflow-auto">
|
||||||
<h2 className="text-lg font-semibold">设置</h2>
|
<h2 className="text-lg font-semibold text-zinc-800">设置</h2>
|
||||||
{configKeys.map(({ key, label }) => (
|
|
||||||
<div key={key}>
|
<section className="space-y-4">
|
||||||
<label className="text-xs text-zinc-500">{label}</label>
|
<h3 className="text-sm font-medium text-zinc-500 uppercase tracking-wide">API 配置</h3>
|
||||||
<textarea
|
<div className="space-y-3 bg-white rounded-lg border border-zinc-200 p-4">
|
||||||
value={configs[key] || '{}'}
|
<div>
|
||||||
onChange={(e) => setConfigs((c) => ({ ...c, [key]: e.target.value }))}
|
<label className="text-xs font-medium text-zinc-500">模型</label>
|
||||||
rows={6}
|
<Input
|
||||||
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm font-mono resize-y"
|
value={form.model}
|
||||||
/>
|
onChange={(e) => handleChange('model', e.target.value)}
|
||||||
<Button
|
placeholder="deepseek-v4-pro[1m]"
|
||||||
size="sm"
|
className="mt-1 bg-zinc-50 border-zinc-200"
|
||||||
variant="outline"
|
/>
|
||||||
className="mt-1"
|
</div>
|
||||||
disabled={saving}
|
<div>
|
||||||
onClick={() => handleSave(key, configs[key])}
|
<label className="text-xs font-medium text-zinc-500">Base URL</label>
|
||||||
>
|
<Input
|
||||||
保存
|
value={form.baseUrl}
|
||||||
</Button>
|
onChange={(e) => handleChange('baseUrl', e.target.value)}
|
||||||
|
placeholder="https://api.deepseek.com/anthropic"
|
||||||
|
className="mt-1 bg-zinc-50 border-zinc-200 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-zinc-500">Auth Token</label>
|
||||||
|
<Input
|
||||||
|
value={form.authToken}
|
||||||
|
onChange={(e) => handleChange('authToken', e.target.value)}
|
||||||
|
type="password"
|
||||||
|
placeholder="sk-..."
|
||||||
|
className="mt-1 bg-zinc-50 border-zinc-200 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-zinc-500 uppercase tracking-wide">默认参数</h3>
|
||||||
|
<div className="space-y-3 bg-white rounded-lg border border-zinc-200 p-4">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-zinc-500">默认生图模型</label>
|
||||||
|
<select
|
||||||
|
value={form.defaultImageModel}
|
||||||
|
onChange={(e) => handleChange('defaultImageModel', e.target.value)}
|
||||||
|
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">未设置</option>
|
||||||
|
<option value="gemini">Gemini</option>
|
||||||
|
<option value="mj">Midjourney</option>
|
||||||
|
<option value="gpt">GPT Image</option>
|
||||||
|
<option value="kling">Kling</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-zinc-500">默认视频模型</label>
|
||||||
|
<select
|
||||||
|
value={form.defaultVideoModel}
|
||||||
|
onChange={(e) => handleChange('defaultVideoModel', e.target.value)}
|
||||||
|
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">未设置</option>
|
||||||
|
<option value="veo3-fast">Veo3 Fast</option>
|
||||||
|
<option value="veo3-fast-frames">Veo3 Fast Frames</option>
|
||||||
|
<option value="kling">Kling</option>
|
||||||
|
<option value="grok">Grok</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-zinc-500">默认画幅</label>
|
||||||
|
<select
|
||||||
|
value={form.defaultFormat}
|
||||||
|
onChange={(e) => handleChange('defaultFormat', e.target.value)}
|
||||||
|
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">未设置</option>
|
||||||
|
<option value="9:16">9:16 竖屏</option>
|
||||||
|
<option value="16:9">16:9 横屏</option>
|
||||||
|
<option value="1:1">1:1 方形</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-zinc-500 uppercase tracking-wide">OSS 存储</h3>
|
||||||
|
<div className="space-y-3 bg-white rounded-lg border border-zinc-200 p-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-zinc-500">Endpoint</label>
|
||||||
|
<Input
|
||||||
|
value={form.ossEndpoint}
|
||||||
|
onChange={(e) => handleChange('ossEndpoint', e.target.value)}
|
||||||
|
placeholder="https://oss-cn-hangzhou.aliyuncs.com"
|
||||||
|
className="mt-1 bg-zinc-50 border-zinc-200 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-zinc-500">Bucket</label>
|
||||||
|
<Input
|
||||||
|
value={form.ossBucket}
|
||||||
|
onChange={(e) => handleChange('ossBucket', e.target.value)}
|
||||||
|
placeholder="my-bucket"
|
||||||
|
className="mt-1 bg-zinc-50 border-zinc-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Button onClick={handleSave} disabled={saving} className="w-full">
|
||||||
|
{saved ? '已保存' : saving ? '保存中...' : '保存设置'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { MiddlePanel } from './MiddlePanel';
|
import { MiddlePanel } from './MiddlePanel';
|
||||||
import { useAppStore } from '@/store';
|
|
||||||
|
|
||||||
export function AppLayout({ children }: { children: React.ReactNode }) {
|
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
const activeView = useAppStore((s) => s.activeView);
|
const { pathname } = useLocation();
|
||||||
|
const isChat = pathname.startsWith('/chat');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex bg-zinc-950 text-zinc-50 overflow-hidden">
|
<div className="h-screen flex bg-white text-zinc-900 overflow-hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
{activeView === 'chat' && <MiddlePanel />}
|
{isChat && <MiddlePanel />}
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
<main className="flex-1 flex flex-col min-w-0 bg-zinc-50">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Plus } from 'lucide-react';
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
@@ -7,8 +8,10 @@ import { useAppStore } from '@/store';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
export function MiddlePanel() {
|
export function MiddlePanel() {
|
||||||
const { conversations, activeConversationId, setActiveConversationId, setConversations } = useAppStore();
|
const { conversations, setConversations } = useAppStore();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { conversationId } = useParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.listConversations().then(setConversations).catch(() => {});
|
api.listConversations().then(setConversations).catch(() => {});
|
||||||
@@ -24,31 +27,33 @@ export function MiddlePanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-60 flex flex-col border-r border-zinc-800">
|
<aside className="w-60 flex flex-col border-r border-zinc-200 bg-white">
|
||||||
<div className="p-3 flex items-center justify-between">
|
<div className="p-3 flex items-center justify-between border-b border-zinc-100">
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索对话..."
|
placeholder="搜索对话..."
|
||||||
className="h-8 text-xs bg-zinc-900 border-zinc-800"
|
className="h-8 text-xs bg-zinc-50 border-zinc-200"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button size="icon" variant="ghost" className="h-8 w-8 ml-1">
|
<Button size="icon" variant="ghost" className="h-8 w-8 ml-1 text-zinc-500 hover:text-zinc-700" onClick={() => navigate('/chat')}>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="flex-1 px-2">
|
<ScrollArea className="flex-1 px-2 py-1">
|
||||||
{conversations.map((conv) => (
|
{conversations.map((conv) => (
|
||||||
<button
|
<button
|
||||||
key={conv.id}
|
key={conv.id}
|
||||||
onClick={() => setActiveConversationId(conv.id)}
|
onClick={() => navigate(`/chat/${conv.id}`)}
|
||||||
className={`w-full text-left px-3 py-2 rounded-md text-sm truncate mb-0.5 transition-colors
|
className={`w-full text-left px-3 py-2 rounded-md text-sm truncate mb-0.5 transition-colors
|
||||||
${conv.id === activeConversationId ? 'bg-zinc-800 text-white' : 'text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200'}`}
|
${conv.id === conversationId
|
||||||
|
? 'bg-indigo-50 text-indigo-700 font-medium'
|
||||||
|
: 'text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900'}`}
|
||||||
>
|
>
|
||||||
{conv.title}
|
{conv.title}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{conversations.length === 0 && (
|
{conversations.length === 0 && (
|
||||||
<p className="text-xs text-zinc-600 text-center mt-8">暂无对话</p>
|
<p className="text-xs text-zinc-400 text-center mt-8">暂无对话</p>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,34 +1,33 @@
|
|||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
import { MessageCircle, FolderOpen, Image, Settings } from 'lucide-react';
|
import { MessageCircle, FolderOpen, Image, Settings } from 'lucide-react';
|
||||||
import { useAppStore } from '@/store';
|
|
||||||
import type { NavView } from '@/types';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const navItems: { id: NavView; icon: typeof MessageCircle; label: string }[] = [
|
const navItems = [
|
||||||
{ id: 'chat', icon: MessageCircle, label: '对话' },
|
{ to: '/chat', icon: MessageCircle, label: '对话' },
|
||||||
{ id: 'accounts', icon: FolderOpen, label: '账户' },
|
{ to: '/accounts', icon: FolderOpen, label: '账户' },
|
||||||
{ id: 'assets', icon: Image, label: '资产' },
|
{ to: '/assets', icon: Image, label: '资产' },
|
||||||
{ id: 'config', icon: Settings, label: '设置' },
|
{ to: '/settings', icon: Settings, label: '设置' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { activeView, setActiveView } = useAppStore();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-14 flex flex-col items-center py-4 gap-2 border-r border-zinc-800">
|
<aside className="w-16 flex flex-col items-center py-4 gap-1 border-r border-zinc-200 bg-zinc-50">
|
||||||
{navItems.map(({ id, icon: Icon, label }) => (
|
{navItems.map(({ to, icon: Icon, label }) => (
|
||||||
<button
|
<NavLink
|
||||||
key={id}
|
key={to}
|
||||||
onClick={() => setActiveView(id)}
|
to={to}
|
||||||
className={cn(
|
className={({ isActive }) =>
|
||||||
'w-10 h-10 flex items-center justify-center rounded-lg transition-colors',
|
cn(
|
||||||
activeView === id
|
'w-12 h-12 flex flex-col items-center justify-center gap-0.5 rounded-lg transition-colors text-[10px] font-medium',
|
||||||
? 'bg-zinc-800 text-white'
|
isActive
|
||||||
: 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/50'
|
? 'bg-indigo-50 text-indigo-600'
|
||||||
)}
|
: 'text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100'
|
||||||
title={label}
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Icon size={20} />
|
<Icon size={18} />
|
||||||
</button>
|
<span>{label}</span>
|
||||||
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function PromptEditor() {
|
|||||||
<select
|
<select
|
||||||
value={selectedAccount}
|
value={selectedAccount}
|
||||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||||
className="mt-1 w-full h-9 rounded-md border border-zinc-800 bg-zinc-900 px-2 text-sm"
|
className="mt-1 w-full h-9 rounded-md border border-zinc-200 bg-zinc-50 px-2 text-sm"
|
||||||
>
|
>
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
<option key={a.id} value={a.id}>{a.name}</option>
|
<option key={a.id} value={a.id}>{a.name}</option>
|
||||||
@@ -50,7 +50,7 @@ export function PromptEditor() {
|
|||||||
key={type}
|
key={type}
|
||||||
onClick={() => setSelectedType(type)}
|
onClick={() => setSelectedType(type)}
|
||||||
className={`w-full text-left px-2 py-1.5 rounded text-sm ${
|
className={`w-full text-left px-2 py-1.5 rounded text-sm ${
|
||||||
selectedType === type ? 'bg-zinc-800 text-white' : 'text-zinc-500 hover:text-zinc-300'
|
selectedType === type ? 'bg-indigo-50 text-indigo-700 font-medium' : 'text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -74,7 +74,7 @@ export function PromptEditor() {
|
|||||||
<textarea
|
<textarea
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
className="flex-1 w-full bg-zinc-950 text-zinc-200 font-mono text-sm p-4 resize-none outline-none"
|
className="flex-1 w-full bg-white text-zinc-700 font-mono text-sm p-4 resize-none outline-none"
|
||||||
placeholder="加载中..."
|
placeholder="加载中..."
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,20 +4,21 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-zinc-800;
|
@apply border-zinc-200;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-zinc-950 text-zinc-50;
|
@apply bg-white text-zinc-900;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* GitHub-style Markdown */
|
/* GitHub-style Markdown - light theme */
|
||||||
@layer components {
|
@layer components {
|
||||||
.markdown-body {
|
.markdown-body {
|
||||||
color-scheme: dark;
|
color-scheme: light;
|
||||||
|
color: #1f2328;
|
||||||
}
|
}
|
||||||
.markdown-body h1 { font-size: 1.5em; font-weight: 600; margin: 0.67em 0; padding-bottom: 0.3em; border-bottom: 1px solid #30363d; }
|
.markdown-body h1 { font-size: 1.5em; font-weight: 600; margin: 0.67em 0; padding-bottom: 0.3em; border-bottom: 1px solid #d0d7de; }
|
||||||
.markdown-body h2 { font-size: 1.25em; font-weight: 600; margin: 0.83em 0; padding-bottom: 0.3em; border-bottom: 1px solid #30363d; }
|
.markdown-body h2 { font-size: 1.25em; font-weight: 600; margin: 0.83em 0; padding-bottom: 0.3em; border-bottom: 1px solid #d0d7de; }
|
||||||
.markdown-body h3 { font-size: 1.1em; font-weight: 600; margin: 1em 0 0.25em; }
|
.markdown-body h3 { font-size: 1.1em; font-weight: 600; margin: 1em 0 0.25em; }
|
||||||
.markdown-body h4 { font-size: 1em; font-weight: 600; margin: 1em 0 0.25em; }
|
.markdown-body h4 { font-size: 1em; font-weight: 600; margin: 1em 0 0.25em; }
|
||||||
.markdown-body p { margin: 0 0 0.75em; }
|
.markdown-body p { margin: 0 0 0.75em; }
|
||||||
@@ -27,24 +28,22 @@
|
|||||||
.markdown-body li > p { margin: 0; }
|
.markdown-body li > p { margin: 0; }
|
||||||
.markdown-body ul { list-style-type: disc; }
|
.markdown-body ul { list-style-type: disc; }
|
||||||
.markdown-body ol { list-style-type: decimal; }
|
.markdown-body ol { list-style-type: decimal; }
|
||||||
.markdown-body ul ul { list-style-type: circle; }
|
|
||||||
.markdown-body ul ul ul { list-style-type: square; }
|
|
||||||
.markdown-body blockquote {
|
.markdown-body blockquote {
|
||||||
margin: 0 0 0.75em;
|
margin: 0 0 0.75em;
|
||||||
padding: 0 1em;
|
padding: 0 1em;
|
||||||
color: #8b949e;
|
color: #656d76;
|
||||||
border-left: 3px solid #30363d;
|
border-left: 3px solid #d0d7de;
|
||||||
}
|
}
|
||||||
.markdown-body code {
|
.markdown-body code {
|
||||||
background: rgba(110,118,129,0.2);
|
background: rgba(175,184,193,0.2);
|
||||||
padding: 0.15em 0.4em;
|
padding: 0.15em 0.4em;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.875em;
|
font-size: 0.875em;
|
||||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
}
|
}
|
||||||
.markdown-body pre {
|
.markdown-body pre {
|
||||||
background: #161b22;
|
background: #f6f8fa;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #d0d7de;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin: 0 0 0.75em;
|
margin: 0 0 0.75em;
|
||||||
@@ -55,35 +54,19 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 0.8125em;
|
font-size: 0.8125em;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #e6edf3;
|
color: #1f2328;
|
||||||
}
|
}
|
||||||
.markdown-body hr {
|
.markdown-body hr {
|
||||||
border: 0;
|
border: 0;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: #30363d;
|
background: #d0d7de;
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
}
|
}
|
||||||
.markdown-body a {
|
.markdown-body a { color: #0969da; text-decoration: none; }
|
||||||
color: #58a6ff;
|
.markdown-body a:hover { text-decoration: underline; }
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.markdown-body a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.markdown-body strong { font-weight: 600; }
|
.markdown-body strong { font-weight: 600; }
|
||||||
.markdown-body table {
|
.markdown-body table { border-collapse: collapse; margin: 0 0 0.75em; width: 100%; }
|
||||||
border-collapse: collapse;
|
.markdown-body th, .markdown-body td { border: 1px solid #d0d7de; padding: 6px 13px; text-align: left; }
|
||||||
margin: 0 0 0.75em;
|
.markdown-body th { font-weight: 600; background: #f6f8fa; }
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.markdown-body th, .markdown-body td {
|
|
||||||
border: 1px solid #30363d;
|
|
||||||
padding: 6px 13px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.markdown-body th {
|
|
||||||
font-weight: 600;
|
|
||||||
background: rgba(110,118,129,0.1);
|
|
||||||
}
|
|
||||||
.markdown-body img { max-width: 100%; border-radius: 6px; }
|
.markdown-body img { max-width: 100%; border-radius: 6px; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { NavView, Conversation } from '@/types';
|
import type { Conversation } from '@/types';
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
activeView: NavView;
|
|
||||||
setActiveView: (view: NavView) => void;
|
|
||||||
selectedAccountId: string | null;
|
selectedAccountId: string | null;
|
||||||
setSelectedAccountId: (id: string | null) => void;
|
setSelectedAccountId: (id: string | null) => void;
|
||||||
activeConversationId: string | null;
|
|
||||||
setActiveConversationId: (id: string | null) => void;
|
|
||||||
conversations: Conversation[];
|
conversations: Conversation[];
|
||||||
setConversations: (list: Conversation[]) => void;
|
setConversations: (list: Conversation[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppStore = create<AppState>((set) => ({
|
export const useAppStore = create<AppState>((set) => ({
|
||||||
activeView: 'chat',
|
|
||||||
setActiveView: (view) => set({ activeView: view }),
|
|
||||||
selectedAccountId: null,
|
selectedAccountId: null,
|
||||||
setSelectedAccountId: (id) => set({ selectedAccountId: id }),
|
setSelectedAccountId: (id) => set({ selectedAccountId: id }),
|
||||||
activeConversationId: null,
|
|
||||||
setActiveConversationId: (id) => set({ activeConversationId: id }),
|
|
||||||
conversations: [],
|
conversations: [],
|
||||||
setConversations: (list) => set({ conversations: list }),
|
setConversations: (list) => set({ conversations: list }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
export type NavView = 'chat' | 'accounts' | 'assets' | 'config';
|
|
||||||
|
|
||||||
export interface Account {
|
export interface Account {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -20,29 +20,29 @@ export default defineConfig({
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
border: 'hsl(240 3.7% 15.9%)',
|
border: 'hsl(240 5.9% 90%)',
|
||||||
input: 'hsl(240 3.7% 15.9%)',
|
input: 'hsl(240 5.9% 90%)',
|
||||||
ring: 'hsl(240 4.9% 83.9%)',
|
ring: 'hsl(240 5% 64.9%)',
|
||||||
background: 'hsl(240 10% 3.9%)',
|
background: 'hsl(0 0% 100%)',
|
||||||
foreground: 'hsl(0 0% 98%)',
|
foreground: 'hsl(240 10% 3.9%)',
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: 'hsl(0 0% 98%)',
|
DEFAULT: 'hsl(240 5.9% 10%)',
|
||||||
foreground: 'hsl(240 5.9% 10%)',
|
foreground: 'hsl(0 0% 98%)',
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: 'hsl(240 3.7% 15.9%)',
|
DEFAULT: 'hsl(240 4.8% 95.9%)',
|
||||||
foreground: 'hsl(0 0% 98%)',
|
foreground: 'hsl(240 5.9% 10%)',
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: 'hsl(240 3.7% 15.9%)',
|
DEFAULT: 'hsl(240 4.8% 95.9%)',
|
||||||
foreground: 'hsl(240 5% 64.9%)',
|
foreground: 'hsl(240 3.8% 46.1%)',
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: 'hsl(240 3.7% 15.9%)',
|
DEFAULT: 'hsl(240 4.8% 95.9%)',
|
||||||
foreground: 'hsl(0 0% 98%)',
|
foreground: 'hsl(240 5.9% 10%)',
|
||||||
},
|
},
|
||||||
destructive: {
|
destructive: {
|
||||||
DEFAULT: 'hsl(0 62.8% 30.6%)',
|
DEFAULT: 'hsl(0 84.2% 60.2%)',
|
||||||
foreground: 'hsl(0 0% 98%)',
|
foreground: 'hsl(0 0% 98%)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user