Files
ai-terminal-assistant/packages/web/src/components/ConfigPanel.tsx
T
kurihada 20765efe62 feat(web): 添加响应式布局和 PWA 支持
- 实现移动端响应式适配:抽屉式侧边栏、触摸优化、Safe Area 支持
- 添加 PWA 支持:vite-plugin-pwa、manifest、Service Worker 缓存
- 优化移动端输入体验:防缩放、最小触摸目标、键盘适配
2025-12-12 13:56:52 +08:00

268 lines
9.6 KiB
TypeScript

/**
* ConfigPanel Component
*
* 配置面板:移动端全屏显示,桌面端居中弹窗
*/
import { useState, useEffect } from 'react';
import { getConfig, updateConfig, type ServerConfig } from '../api/client';
interface ConfigPanelProps {
onClose: () => void;
}
// 可用的模型列表
const AVAILABLE_MODELS = [
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
{ id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet' },
{ id: 'claude-3-opus-20240229', name: 'Claude 3 Opus' },
{ id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku' },
];
export function ConfigPanel({ onClose }: ConfigPanelProps) {
const [config, setConfig] = useState<ServerConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
// 表单状态
const [formData, setFormData] = useState({
model: '',
maxTokens: 8192,
temperature: 0.7,
workdir: '',
});
// 加载配置
useEffect(() => {
async function loadConfig() {
try {
const response = await getConfig();
setConfig(response.data);
setFormData({
model: response.data.model,
maxTokens: response.data.maxTokens,
temperature: response.data.temperature,
workdir: response.data.workdir,
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load config');
} finally {
setLoading(false);
}
}
loadConfig();
}, []);
// 保存配置
const handleSave = async () => {
setSaving(true);
setError(null);
setSuccess(false);
try {
const response = await updateConfig(formData);
setConfig(response.data);
setSuccess(true);
setTimeout(() => setSuccess(false), 2000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save config');
} finally {
setSaving(false);
}
};
// 重置为默认值
const handleReset = () => {
if (config) {
setFormData({
model: config.model,
maxTokens: config.maxTokens,
temperature: config.temperature,
workdir: config.workdir,
});
}
};
if (loading) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-800 rounded-lg p-6">
<div className="text-gray-400">Loading configuration...</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-black/50 flex items-end md:items-center justify-center z-50">
{/* 移动端:从底部滑出的全屏面板;桌面端:居中弹窗 */}
<div className="bg-gray-800 w-full md:w-full md:max-w-lg md:mx-4 max-h-full md:max-h-[90vh] overflow-auto rounded-t-2xl md:rounded-lg">
{/* Header */}
<div className="sticky top-0 flex items-center justify-between px-4 md:px-6 py-4 border-b border-gray-700 bg-gray-800 z-10">
{/* 移动端拖动指示器 */}
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
<h2 className="text-lg font-semibold mt-2 md:mt-0">Configuration</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-700 active:bg-gray-600 rounded-lg transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="p-4 md:p-6 space-y-6">
{/* Error message */}
{error && (
<div className="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm">
{error}
</div>
)}
{/* Success message */}
{success && (
<div className="p-3 bg-green-900/50 border border-green-700 rounded-lg text-green-300 text-sm">
Configuration saved successfully!
</div>
)}
{/* Model */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Model
</label>
<select
value={formData.model}
onChange={(e) => setFormData({ ...formData, model: e.target.value })}
className="w-full px-3 py-3 md:py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-base md:text-sm focus:outline-none focus:border-blue-500"
>
{AVAILABLE_MODELS.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500">
Select the AI model to use for conversations
</p>
</div>
{/* Max Tokens */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Max Tokens: {formData.maxTokens.toLocaleString()}
</label>
<input
type="range"
min="1024"
max="32768"
step="1024"
value={formData.maxTokens}
onChange={(e) => setFormData({ ...formData, maxTokens: parseInt(e.target.value) })}
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer touch-pan-x"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>1K</span>
<span>8K</span>
<span>16K</span>
<span>32K</span>
</div>
<p className="mt-1 text-xs text-gray-500">
Maximum number of tokens in the response
</p>
</div>
{/* Temperature */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Temperature: {formData.temperature.toFixed(2)}
</label>
<input
type="range"
min="0"
max="1"
step="0.05"
value={formData.temperature}
onChange={(e) => setFormData({ ...formData, temperature: parseFloat(e.target.value) })}
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer touch-pan-x"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>Precise</span>
<span>Balanced</span>
<span>Creative</span>
</div>
<p className="mt-1 text-xs text-gray-500">
Controls randomness in responses
</p>
</div>
{/* Working Directory */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Working Directory
</label>
<input
type="text"
value={formData.workdir}
onChange={(e) => setFormData({ ...formData, workdir: e.target.value })}
className="w-full px-3 py-3 md:py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-base md:text-sm focus:outline-none focus:border-blue-500 font-mono"
placeholder="/path/to/project"
/>
<p className="mt-1 text-xs text-gray-500">
Root directory for file operations
</p>
</div>
{/* Server Info (Read-only) */}
{config && (
<div className="pt-4 border-t border-gray-700">
<h3 className="text-sm font-medium text-gray-400 mb-3">Server Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div className="flex justify-between md:block">
<span className="text-gray-500">Allowed Paths:</span>
<span className="md:ml-2 text-gray-300">
{config.allowedPaths.length || 'All'}
</span>
</div>
<div className="flex justify-between md:block">
<span className="text-gray-500">Denied Paths:</span>
<span className="md:ml-2 text-gray-300">
{config.deniedPaths.length || 'None'}
</span>
</div>
</div>
</div>
)}
</div>
{/* Footer - 移动端固定在底部 */}
<div className="sticky bottom-0 flex flex-col-reverse md:flex-row items-stretch md:items-center justify-end gap-2 md:gap-3 p-4 md:px-6 md:py-4 border-t border-gray-700 bg-gray-800 safe-area-pb">
<button
onClick={handleReset}
className="px-4 py-3 md:py-2 text-sm text-gray-300 hover:text-white active:bg-gray-700 rounded-lg transition-colors"
>
Reset
</button>
<button
onClick={onClose}
className="px-4 py-3 md:py-2 text-sm bg-gray-700 hover:bg-gray-600 active:bg-gray-500 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-3 md:py-2 text-sm bg-blue-600 hover:bg-blue-500 active:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
);
}