feat(web): 将原生 select 替换为 shadcn/ui Select 组件并支持图片消息发送
- 在 AccountForm、AssetGallery、ChatView、ConfigForm、PromptEditor 中将 `<select>` 替换为 shadcn/ui 的 Select 组件以统一 UI 风格 - 在 ChatView 和 useChat hook 中支持发送图片附件 - 更新 pi-bridge 和 ws/chat 以处理 agent 调用中的图片数据
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from '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 { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
import type { Account } from '@/types';
|
import type { Account } from '@/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from '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 { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
import type { Account } from '@/types';
|
import type { Account } from '@/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -64,41 +65,44 @@ export function AccountForm({ account, onSave, onDelete, onCancel }: Props) {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-zinc-500">画幅</label>
|
<label className="text-xs text-zinc-500">画幅</label>
|
||||||
<select
|
<Select value={form.defaultFormat} onValueChange={(v) => handleChange('defaultFormat', v)}>
|
||||||
value={form.defaultFormat}
|
<SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
|
||||||
onChange={(e) => handleChange('defaultFormat', e.target.value)}
|
<SelectValue />
|
||||||
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="9:16">9:16 竖屏</option>
|
<SelectItem value="9:16">9:16 竖屏</SelectItem>
|
||||||
<option value="16:9">16:9 横屏</option>
|
<SelectItem value="16:9">16:9 横屏</SelectItem>
|
||||||
<option value="1:1">1:1 方形</option>
|
<SelectItem value="1:1">1:1 方形</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-zinc-500">生图模型</label>
|
<label className="text-xs text-zinc-500">生图模型</label>
|
||||||
<select
|
<Select value={form.imageModel} onValueChange={(v) => handleChange('imageModel', v)}>
|
||||||
value={form.imageModel}
|
<SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
|
||||||
onChange={(e) => handleChange('imageModel', e.target.value)}
|
<SelectValue />
|
||||||
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="gemini">Gemini</option>
|
<SelectItem value="gemini">Gemini</SelectItem>
|
||||||
<option value="mj">Midjourney</option>
|
<SelectItem value="mj">Midjourney</SelectItem>
|
||||||
<option value="gpt">GPT Image</option>
|
<SelectItem value="gpt">GPT Image</SelectItem>
|
||||||
<option value="kling">Kling</option>
|
<SelectItem value="kling">Kling</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-zinc-500">视频模型</label>
|
<label className="text-xs text-zinc-500">视频模型</label>
|
||||||
<select
|
<Select value={form.videoModel} onValueChange={(v) => handleChange('videoModel', v)}>
|
||||||
value={form.videoModel}
|
<SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
|
||||||
onChange={(e) => handleChange('videoModel', e.target.value)}
|
<SelectValue />
|
||||||
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="veo3-fast">Veo3 Fast</option>
|
<SelectItem value="veo3-fast">Veo3 Fast</SelectItem>
|
||||||
<option value="veo3-fast-frames">Veo3 Fast Frames</option>
|
<SelectItem value="veo3-fast-frames">Veo3 Fast Frames</SelectItem>
|
||||||
<option value="kling">Kling</option>
|
<SelectItem value="kling">Kling</SelectItem>
|
||||||
<option value="grok">Grok</option>
|
<SelectItem value="grok">Grok</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useAssets } from '@/hooks/useAssets';
|
|||||||
import { AssetProjectGroup } from './AssetProjectGroup';
|
import { AssetProjectGroup } from './AssetProjectGroup';
|
||||||
import { AssetPreview } from './AssetPreview';
|
import { AssetPreview } from './AssetPreview';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
import type { Asset } from '@/types';
|
import type { Asset } from '@/types';
|
||||||
|
|
||||||
interface ManifestGroup {
|
interface ManifestGroup {
|
||||||
@@ -125,16 +126,17 @@ export function AssetGallery() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select
|
<Select value={accountFilter || 'all'} onValueChange={(v) => setAccountFilter(v === 'all' ? '' : v)}>
|
||||||
value={accountFilter}
|
<SelectTrigger className="h-8 w-auto min-w-[100px] text-xs border-zinc-100 bg-zinc-50/80 hover:border-zinc-200">
|
||||||
onChange={(e) => setAccountFilter(e.target.value)}
|
<SelectValue placeholder="全部账号" />
|
||||||
className="h-8 rounded-lg border border-zinc-100 bg-zinc-50/80 px-2 text-xs hover:border-zinc-200 transition-colors"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="">全部账号</option>
|
<SelectItem value="all">全部账号</SelectItem>
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
<option key={a.id} value={a.id}>{a.name}</option>
|
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button size="sm" variant="outline" className="h-8 text-xs rounded-lg" onClick={handleScan}>
|
<Button size="sm" variant="outline" className="h-8 text-xs rounded-lg" onClick={handleScan}>
|
||||||
|
|||||||
@@ -126,10 +126,10 @@ export function ChatView() {
|
|||||||
creatingRef.current = false;
|
creatingRef.current = false;
|
||||||
}, [createConversation, selectedAccountId]);
|
}, [createConversation, selectedAccountId]);
|
||||||
|
|
||||||
const handleSend = useCallback((content: string) => {
|
const handleSend = useCallback((content: string, images?: Array<{ data: string; mimeType: string }>) => {
|
||||||
if (quote) { content = `> ${quote}\n\n${content}`; setQuote(null); }
|
if (quote) { content = `> ${quote}\n\n${content}`; setQuote(null); }
|
||||||
if (conversationId) {
|
if (conversationId) {
|
||||||
send(content);
|
send(content, images);
|
||||||
} else {
|
} else {
|
||||||
handleSendNew(content);
|
handleSendNew(content);
|
||||||
}
|
}
|
||||||
@@ -154,14 +154,18 @@ export function ChatView() {
|
|||||||
{accounts.length > 0 && (
|
{accounts.length > 0 && (
|
||||||
<div className="flex items-center gap-2 mt-3 justify-center">
|
<div className="flex items-center gap-2 mt-3 justify-center">
|
||||||
<span className="text-xs text-zinc-400">当前账号:</span>
|
<span className="text-xs text-zinc-400">当前账号:</span>
|
||||||
<select
|
<Select
|
||||||
value={selectedAccountId || ''}
|
value={selectedAccountId || ''}
|
||||||
onChange={(e) => useAppStore.getState().setSelectedAccountId(e.target.value || null)}
|
onValueChange={(v) => useAppStore.getState().setSelectedAccountId(v === 'none' ? null : v)}
|
||||||
className="h-7 rounded-lg border border-zinc-100 bg-white px-2.5 text-xs text-zinc-600 hover:border-zinc-200 transition-colors"
|
|
||||||
>
|
>
|
||||||
<option value="">不指定</option>
|
<SelectTrigger className="h-7 w-auto min-w-[100px] text-xs px-2.5 border-zinc-100 hover:border-zinc-200">
|
||||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
<SelectValue placeholder="不指定" />
|
||||||
</select>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">不指定</SelectItem>
|
||||||
|
{accounts.map((a) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{recentConvs.length > 0 && (
|
{recentConvs.length > 0 && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { Input } from '@/components/ui/input';
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Save, Check, Loader2, TestTube2 } from 'lucide-react';
|
import { Save, Check, Loader2, TestTube2 } from 'lucide-react';
|
||||||
|
|
||||||
@@ -272,35 +273,47 @@ export function ConfigForm() {
|
|||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-zinc-500">生图模型</label>
|
<label className="text-xs font-medium text-zinc-500">生图模型</label>
|
||||||
<select value={agentForm.defaultImageModel} onChange={(e) => handleAgentChange('defaultImageModel', e.target.value)}
|
<Select value={agentForm.defaultImageModel || 'none'} onValueChange={(v) => handleAgentChange('defaultImageModel', v === 'none' ? '' : v)}>
|
||||||
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm">
|
<SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
|
||||||
<option value="">未设置</option>
|
<SelectValue placeholder="未设置" />
|
||||||
<option value="gemini">Gemini</option>
|
</SelectTrigger>
|
||||||
<option value="mj">Midjourney</option>
|
<SelectContent>
|
||||||
<option value="gpt">GPT Image</option>
|
<SelectItem value="none">未设置</SelectItem>
|
||||||
<option value="kling">Kling</option>
|
<SelectItem value="gemini">Gemini</SelectItem>
|
||||||
</select>
|
<SelectItem value="mj">Midjourney</SelectItem>
|
||||||
|
<SelectItem value="gpt">GPT Image</SelectItem>
|
||||||
|
<SelectItem value="kling">Kling</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-zinc-500">视频模型</label>
|
<label className="text-xs font-medium text-zinc-500">视频模型</label>
|
||||||
<select value={agentForm.defaultVideoModel} onChange={(e) => handleAgentChange('defaultVideoModel', e.target.value)}
|
<Select value={agentForm.defaultVideoModel || 'none'} onValueChange={(v) => handleAgentChange('defaultVideoModel', v === 'none' ? '' : v)}>
|
||||||
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm">
|
<SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
|
||||||
<option value="">未设置</option>
|
<SelectValue placeholder="未设置" />
|
||||||
<option value="veo3-fast">Veo3 Fast</option>
|
</SelectTrigger>
|
||||||
<option value="veo3-fast-frames">Veo3 Fast Frames</option>
|
<SelectContent>
|
||||||
<option value="kling">Kling</option>
|
<SelectItem value="none">未设置</SelectItem>
|
||||||
<option value="grok">Grok</option>
|
<SelectItem value="veo3-fast">Veo3 Fast</SelectItem>
|
||||||
</select>
|
<SelectItem value="veo3-fast-frames">Veo3 Fast Frames</SelectItem>
|
||||||
|
<SelectItem value="kling">Kling</SelectItem>
|
||||||
|
<SelectItem value="grok">Grok</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-zinc-500">画幅</label>
|
<label className="text-xs font-medium text-zinc-500">画幅</label>
|
||||||
<select value={agentForm.defaultFormat} onChange={(e) => handleAgentChange('defaultFormat', e.target.value)}
|
<Select value={agentForm.defaultFormat || 'none'} onValueChange={(v) => handleAgentChange('defaultFormat', v === 'none' ? '' : v)}>
|
||||||
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm">
|
<SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
|
||||||
<option value="">未设置</option>
|
<SelectValue placeholder="未设置" />
|
||||||
<option value="9:16">9:16 竖屏</option>
|
</SelectTrigger>
|
||||||
<option value="16:9">16:9 横屏</option>
|
<SelectContent>
|
||||||
<option value="1:1">1:1 方形</option>
|
<SelectItem value="none">未设置</SelectItem>
|
||||||
</select>
|
<SelectItem value="9:16">9:16 竖屏</SelectItem>
|
||||||
|
<SelectItem value="16:9">16:9 横屏</SelectItem>
|
||||||
|
<SelectItem value="1:1">1:1 方形</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'prismjs/themes/prism.css';
|
|||||||
import { useAccounts } from '@/hooks/useAccounts';
|
import { useAccounts } from '@/hooks/useAccounts';
|
||||||
import { usePrompts } from '@/hooks/usePrompts';
|
import { usePrompts } from '@/hooks/usePrompts';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
|
|
||||||
const PROMPT_TYPES = [
|
const PROMPT_TYPES = [
|
||||||
{ type: 'storyboard', label: '分镜' },
|
{ type: 'storyboard', label: '分镜' },
|
||||||
@@ -54,15 +55,16 @@ export function PromptEditor({ accountId: accountIdProp }: { accountId?: string
|
|||||||
<div className="w-48 border-r border-zinc-200 p-3 space-y-3 bg-zinc-50">
|
<div className="w-48 border-r border-zinc-200 p-3 space-y-3 bg-zinc-50">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-zinc-500">账户</label>
|
<label className="text-xs font-medium text-zinc-500">账户</label>
|
||||||
<select
|
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
|
||||||
value={selectedAccount}
|
<SelectTrigger className="mt-1 w-full h-9 text-sm">
|
||||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
<SelectValue placeholder="选择账户" />
|
||||||
className="mt-1 w-full h-9 rounded-md border border-zinc-200 bg-white px-2 text-sm text-zinc-700"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
<option key={a.id} value={a.id}>{a.name}</option>
|
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-zinc-500">模板</label>
|
<label className="text-xs font-medium text-zinc-500">模板</label>
|
||||||
|
|||||||
@@ -150,8 +150,8 @@ export function useChat(conversationId: string | null) {
|
|||||||
}
|
}
|
||||||
}, [conversationId, connected]);
|
}, [conversationId, connected]);
|
||||||
|
|
||||||
const send = useCallback((content: string) => {
|
const send = useCallback((content: string, images?: Array<{ data: string; mimeType: string }>) => {
|
||||||
chatSocket.send('chat', { content });
|
chatSocket.send('chat', { content, images });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ interface RunContext {
|
|||||||
currentAssistantMsgId: string | null;
|
currentAssistantMsgId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runAgentChat(ws: WebSocket, convId: string, userContent: string) {
|
export async function runAgentChat(ws: WebSocket, convId: string, userContent: string, images?: Array<{ type: 'image'; data: string; mimeType: string }>) {
|
||||||
const userMsgId = saveUserMessage(convId, userContent);
|
const userMsgId = saveUserMessage(convId, userContent);
|
||||||
ws.send(JSON.stringify({ type: 'message', data: { id: userMsgId, role: 'user', content: userContent } }));
|
ws.send(JSON.stringify({ type: 'message', data: { id: userMsgId, role: 'user', content: userContent } }));
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ export async function runAgentChat(ws: WebSocket, convId: string, userContent: s
|
|||||||
ws.send(JSON.stringify({ type: 'status', data: { status: 'thinking' } }));
|
ws.send(JSON.stringify({ type: 'status', data: { status: 'thinking' } }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await agent.prompt(userContent);
|
await agent.prompt(userContent, images);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = (err as Error).message;
|
const errMsg = (err as Error).message;
|
||||||
console.error('[pi-bridge] Agent error:', errMsg);
|
console.error('[pi-bridge] Agent error:', errMsg);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function handleChat(ws: WebSocket) {
|
|||||||
ws.send(JSON.stringify({ type: 'error', data: { message: '没有活跃对话,请先创建或选择一个对话' } }));
|
ws.send(JSON.stringify({ type: 'error', data: { message: '没有活跃对话,请先创建或选择一个对话' } }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runAgentChat(ws, conversationId, msg.content);
|
await runAgentChat(ws, conversationId, msg.content, msg.images);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('WebSocket error:', e);
|
console.error('WebSocket error:', e);
|
||||||
|
|||||||
Reference in New Issue
Block a user