fix webui i18n

This commit is contained in:
lpf
2026-03-03 12:02:57 +08:00
parent 75abdcdf07
commit cdc39231db
19 changed files with 1019 additions and 237 deletions

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { useTranslation } from 'react-i18next';
type DialogOptions = {
title?: string;
@@ -16,6 +17,7 @@ export const GlobalDialog: React.FC<{
onConfirm: () => void;
onCancel: () => void;
}> = ({ open, kind, options, onConfirm, onCancel }) => {
const { t } = useTranslation();
return (
<AnimatePresence>
{open && (
@@ -24,15 +26,15 @@ export const GlobalDialog: React.FC<{
<motion.div className="w-full max-w-md rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl"
initial={{ scale: 0.95, y: 8 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.95, y: 8 }}>
<div className="px-5 py-4 border-b border-zinc-800">
<h3 className="text-sm font-semibold text-zinc-100">{options.title || (kind === 'confirm' ? 'Please confirm' : 'Notice')}</h3>
<h3 className="text-sm font-semibold text-zinc-100">{options.title || (kind === 'confirm' ? t('dialogPleaseConfirm') : t('dialogNotice'))}</h3>
</div>
<div className="px-5 py-4 text-sm text-zinc-300 whitespace-pre-wrap">{options.message}</div>
<div className="px-5 pb-5 flex items-center justify-end gap-2">
{kind === 'confirm' && (
<button onClick={onCancel} className="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-sm">{options.cancelText || 'Cancel'}</button>
<button onClick={onCancel} className="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-sm">{options.cancelText || t('cancel')}</button>
)}
<button onClick={onConfirm} className={`px-3 py-1.5 rounded-lg text-sm ${options.danger ? 'bg-red-600 hover:bg-red-500 text-white' : 'bg-indigo-600 hover:bg-indigo-500 text-white'}`}>
{options.confirmText || 'OK'}
{options.confirmText || t('dialogOk')}
</button>
</div>
</motion.div>

View File

@@ -21,7 +21,7 @@ const Header: React.FC = () => {
<div className="w-8 h-8 md:w-9 md:h-9 rounded-xl bg-indigo-500 flex items-center justify-center shadow-lg shadow-indigo-500/20 shrink-0">
<Terminal className="w-4 h-4 md:w-5 md:h-5 text-white" />
</div>
<span className="hidden md:inline font-semibold text-lg md:text-xl tracking-tight truncate">ClawGo</span>
<span className="hidden md:inline font-semibold text-lg md:text-xl tracking-tight truncate">{t('appName')}</span>
</div>
<div className="flex items-center gap-2 md:gap-6">

View File

@@ -25,6 +25,7 @@ const PrimitiveArrayEditor: React.FC<{
path: string;
onChange: (next: any[]) => void;
}> = ({ value, path, onChange }) => {
const { t } = useTranslation();
const [draft, setDraft] = useState('');
const [selected, setSelected] = useState('');
@@ -54,7 +55,7 @@ const PrimitiveArrayEditor: React.FC<{
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{value.length === 0 && <span className="text-xs text-zinc-500 italic">(empty)</span>}
{value.length === 0 && <span className="text-xs text-zinc-500 italic">{t('empty')}</span>}
{value.map((item, idx) => (
<span key={`${item}-${idx}`} className="inline-flex items-center gap-1 px-2 py-1 rounded bg-zinc-900 border border-zinc-700 text-xs font-mono text-zinc-200">
{String(item)}
@@ -68,7 +69,7 @@ const PrimitiveArrayEditor: React.FC<{
list={`${path}-suggestions`}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="输入新值后添加"
placeholder={t('recursiveAddValuePlaceholder')}
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500"
/>
<datalist id={`${path}-suggestions`}>
@@ -84,7 +85,7 @@ const PrimitiveArrayEditor: React.FC<{
}}
className="px-3 py-2 text-xs rounded-lg bg-zinc-800 hover:bg-zinc-700"
>
{t('add')}
</button>
<select
@@ -96,7 +97,7 @@ const PrimitiveArrayEditor: React.FC<{
}}
className="px-3 py-2 text-xs rounded-lg bg-zinc-950 border border-zinc-800"
>
<option value=""></option>
<option value="">{t('recursiveSelectOption')}</option>
{suggestions.filter((s) => !value.includes(s)).map((s) => (
<option key={s} value={s}>{s}</option>
))}

View File

@@ -18,6 +18,8 @@ interface AppContextType {
setCron: (cron: CronJob[]) => void;
skills: Skill[];
setSkills: (skills: Skill[]) => void;
clawhubInstalled: boolean;
clawhubPath: string;
sessions: Session[];
setSessions: React.Dispatch<React.SetStateAction<Session[]>>;
refreshAll: () => Promise<void>;
@@ -53,6 +55,8 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [nodes, setNodes] = useState('[]');
const [cron, setCron] = useState<CronJob[]>([]);
const [skills, setSkills] = useState<Skill[]>([]);
const [clawhubInstalled, setClawhubInstalled] = useState(false);
const [clawhubPath, setClawhubPath] = useState('');
const [sessions, setSessions] = useState<Session[]>([{ key: 'main', title: 'main' }]);
const [gatewayVersion, setGatewayVersion] = useState('unknown');
const [webuiVersion, setWebuiVersion] = useState('unknown');
@@ -121,6 +125,8 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (!r.ok) throw new Error('Failed to load skills');
const j = await r.json();
setSkills(Array.isArray(j.skills) ? j.skills : []);
setClawhubInstalled(!!j.clawhub_installed);
setClawhubPath(typeof j.clawhub_path === 'string' ? j.clawhub_path : '');
setIsGatewayOnline(true);
} catch (e) {
setIsGatewayOnline(false);
@@ -174,7 +180,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
<AppContext.Provider value={{
token, setToken, sidebarOpen, setSidebarOpen, isGatewayOnline, setIsGatewayOnline,
cfg, setCfg, cfgRaw, setCfgRaw, nodes, setNodes,
cron, setCron, skills, setSkills,
cron, setCron, skills, setSkills, clawhubInstalled, clawhubPath,
sessions, setSessions,
refreshAll, refreshCron, refreshNodes, refreshSkills, refreshSessions, refreshVersion, loadConfig,
gatewayVersion, webuiVersion, hotReloadFields, hotReloadFieldDetails, q

View File

@@ -1,5 +1,6 @@
import React, { createContext, useContext, useMemo, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { useTranslation } from 'react-i18next';
import { GlobalDialog, DialogOptions } from '../components/GlobalDialog';
type UIContextType = {
@@ -15,15 +16,16 @@ type UIContextType = {
const UIContext = createContext<UIContextType | undefined>(undefined);
export const UIProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [loadingText, setLoadingText] = useState('Loading...');
const [loadingText, setLoadingText] = useState(t('loading'));
const [dialog, setDialog] = useState<null | { kind: 'notice' | 'confirm'; options: DialogOptions; resolve: (v: any) => void }>(null);
const [customModal, setCustomModal] = useState<null | { title?: string; node: React.ReactNode }>(null);
const value = useMemo<UIContextType>(() => ({
loading,
showLoading: (text?: string) => {
setLoadingText(text || 'Loading...');
setLoadingText(text || t('loading'));
setLoading(true);
},
hideLoading: () => setLoading(false),
@@ -37,7 +39,7 @@ export const UIProvider: React.FC<{ children: React.ReactNode }> = ({ children }
}),
openModal: (node, title) => setCustomModal({ node, title }),
closeModal: () => setCustomModal(null),
}), [loading]);
}), [loading, t]);
const closeDialog = (result: boolean) => {
if (!dialog) return;
@@ -76,7 +78,7 @@ export const UIProvider: React.FC<{ children: React.ReactNode }> = ({ children }
<motion.div className="w-full max-w-4xl rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl overflow-hidden"
initial={{ scale: 0.96 }} animate={{ scale: 1 }} exit={{ scale: 0.96 }}>
<div className="px-5 py-3 border-b border-zinc-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-100">{customModal.title || 'Modal'}</h3>
<h3 className="text-sm font-semibold text-zinc-100">{customModal.title || t('modal')}</h3>
<button onClick={() => setCustomModal(null)} className="text-zinc-400 hover:text-zinc-200"></button>
</div>
<div className="p-4 max-h-[80vh] overflow-auto">{customModal.node}</div>

View File

@@ -107,6 +107,118 @@ const resources = {
save: 'Save',
create: 'Create',
update: 'Update',
delete: 'Delete',
add: 'Add',
install: 'Install',
installed: 'Installed',
notInstalled: 'Not Installed',
status: 'Status',
source: 'Source',
duration: 'Duration',
provider: 'Provider',
model: 'Model',
time: 'Time',
level: 'Level',
code: 'Code',
template: 'Template',
content: 'Content',
id: 'ID',
files: 'Files',
close: 'Close',
path: 'Path',
before: 'Before',
after: 'After',
hide: 'Hide',
show: 'Show',
clear: 'Clear',
pause: 'Pause',
resume: 'Resume',
live: 'Live',
raw: 'Raw',
pretty: 'Pretty',
entries: 'entries',
appName: 'ClawGo',
webui: 'WebUI',
node: 'Node',
unknownIp: 'Unknown IP',
memoryFiles: 'Memory Files',
memoryFileNamePrompt: 'Memory file name',
noFileSelected: 'No file selected',
noDescription: 'No description provided.',
empty: '(empty)',
modal: 'Modal',
dialogPleaseConfirm: 'Please confirm',
dialogNotice: 'Notice',
dialogOk: 'OK',
requestFailed: 'Request Failed',
saved: 'Saved',
reloadHistory: 'Reload History',
user: 'User',
exec: 'Exec',
agent: 'Agent',
toolOutput: 'tool output',
chatServerError: 'Error: Failed to get response from server.',
waitingForLogs: 'Waiting for logs...',
systemLog: 'system.log',
codeCaller: 'Code/Caller',
recursiveAddValuePlaceholder: 'Type new value and add',
recursiveSelectOption: 'Select option',
logCodesSearchPlaceholder: 'Search code/text',
logCodesNoCodes: 'No codes',
skillsDeleteTitle: 'Delete Skill',
skillsDeleteMessage: 'Are you sure you want to delete this skill?',
skillsClawhubMissingTitle: 'clawhub not detected',
skillsClawhubMissingMessage: 'clawhub is not installed. Install dependencies and clawhub automatically?',
skillsInstallNow: 'Install now',
skillsInstallingDeps: 'Installing node/npm and clawhub...',
skillsInstallFailedTitle: 'Install failed',
skillsInstallFailedMessage: 'Failed to install clawhub',
skillsInstallDoneTitle: 'Install complete',
skillsInstallDoneMessage: 'clawhub is installed. You can continue installing skills.',
skillsAddTitle: 'Add Skill',
skillsAddMessage: 'Upload skill archive (.zip / .tar.gz / .tgz / .tar). It will be extracted into skills folder and archive will be removed.',
skillsSelectArchive: 'Select archive',
skillsImporting: 'Uploading and importing skill...',
skillsImportFailedTitle: 'Import failed',
skillsImportFailedMessage: 'Failed to import skill archive',
skillsImportDoneTitle: 'Import complete',
skillsImportedPrefix: 'Imported',
skillsImportDoneMessage: 'Skill imported successfully.',
skillsFileSaved: 'Skill file saved successfully.',
skillsNamePlaceholder: 'skill name',
skillsClawhubNotFound: 'clawhub not found',
skillsClawhubStatus: 'clawhub',
skillsAdd: 'Add Skill',
skillsNoTools: 'No tools defined',
skillsFileEdit: 'File Edit',
configDiffPreview: 'Diff Preview',
configBasicMode: 'Basic Mode',
configAdvancedMode: 'Advanced Mode',
configHotOnly: 'Hot-reload fields only',
configSearchPlaceholder: 'Search group...',
configHotFieldsFull: 'Hot-reload fields (full)',
configTopLevel: 'Top Level',
configProxies: 'Proxies',
configNewProviderName: 'new provider name',
configNoCustomProviders: 'No custom providers yet.',
configNoGroups: 'No config groups found.',
configDiffPreviewCount: 'Diff Preview ({{count}} items)',
saveConfigFailed: 'Failed to save config',
sourceAutonomy: 'autonomy',
sourceDirect: 'direct',
sourceMemoryTodo: 'memory_todo',
statusRunning: 'running',
statusWaiting: 'waiting',
statusBlocked: 'blocked',
statusSuccess: 'success',
statusError: 'error',
statusSuppressed: 'suppressed',
taskId: 'Task ID',
inputPreview: 'Input Preview',
blockReason: 'Block Reason',
actionFailed: 'Action failed',
cronExpressionPlaceholder: '*/5 * * * *',
recipientId: 'recipient id',
configLabels: {
gateway: 'Gateway',
host: 'Host',
@@ -157,6 +269,8 @@ const resources = {
google_maps: 'Google Maps',
url_context: 'URL Context',
api_base: 'API Base',
protocol: 'Protocol',
models: 'Models',
organization: 'Organization',
project: 'Project',
region: 'Region',
@@ -292,6 +406,118 @@ const resources = {
save: '保存',
create: '创建',
update: '更新',
delete: '删除',
add: '添加',
install: '安装',
installed: '已安装',
notInstalled: '未安装',
status: '状态',
source: '来源',
duration: '耗时',
provider: '提供商',
model: '模型',
time: '时间',
level: '级别',
code: '代码',
template: '模板',
content: '内容',
id: 'ID',
files: '文件',
close: '关闭',
path: '路径',
before: '变更前',
after: '变更后',
hide: '隐藏',
show: '显示',
clear: '清空',
pause: '暂停',
resume: '继续',
live: '实时',
raw: '原始',
pretty: '格式化',
entries: '条',
appName: 'ClawGo',
webui: 'WebUI',
node: '节点',
unknownIp: '未知 IP',
memoryFiles: '记忆文件',
memoryFileNamePrompt: '记忆文件名',
noFileSelected: '未选择文件',
noDescription: '暂无描述。',
empty: '(空)',
modal: '弹窗',
dialogPleaseConfirm: '请确认',
dialogNotice: '提示',
dialogOk: '确定',
requestFailed: '请求失败',
saved: '已保存',
reloadHistory: '重新加载历史',
user: '用户',
exec: '执行',
agent: '助手',
toolOutput: '工具输出',
chatServerError: '错误:获取服务端响应失败。',
waitingForLogs: '等待日志中...',
systemLog: '系统日志',
codeCaller: '代码/来源',
recursiveAddValuePlaceholder: '输入新值后添加',
recursiveSelectOption: '下拉选择',
logCodesSearchPlaceholder: '搜索代码/文本',
logCodesNoCodes: '暂无代码',
skillsDeleteTitle: '删除技能',
skillsDeleteMessage: '确认删除这个技能吗?',
skillsClawhubMissingTitle: '未检测到 clawhub',
skillsClawhubMissingMessage: '检测到系统中未安装 clawhub。是否自动安装依赖环境并安装 clawhub',
skillsInstallNow: '立即安装',
skillsInstallingDeps: '正在安装 node/npm 与 clawhub...',
skillsInstallFailedTitle: '安装失败',
skillsInstallFailedMessage: '安装 clawhub 失败',
skillsInstallDoneTitle: '安装完成',
skillsInstallDoneMessage: 'clawhub 已安装,可继续安装技能。',
skillsAddTitle: '添加技能',
skillsAddMessage: '请上传技能压缩包(.zip / .tar.gz / .tgz / .tar。上传后将自动解压到 skills 目录,并删除上传压缩包。',
skillsSelectArchive: '选择压缩包',
skillsImporting: '正在上传并导入技能...',
skillsImportFailedTitle: '导入失败',
skillsImportFailedMessage: '技能压缩包导入失败',
skillsImportDoneTitle: '导入完成',
skillsImportedPrefix: '已导入',
skillsImportDoneMessage: '技能导入成功。',
skillsFileSaved: '技能文件保存成功。',
skillsNamePlaceholder: '技能名',
skillsClawhubNotFound: '未找到 clawhub',
skillsClawhubStatus: 'clawhub',
skillsAdd: '添加技能',
skillsNoTools: '未定义工具',
skillsFileEdit: '文件编辑',
configDiffPreview: '差异预览',
configBasicMode: '基础模式',
configAdvancedMode: '高级模式',
configHotOnly: '仅热更新字段',
configSearchPlaceholder: '搜索分类...',
configHotFieldsFull: '热更新字段(完整)',
configTopLevel: '顶层分类',
configProxies: '代理配置',
configNewProviderName: '新 provider 名称',
configNoCustomProviders: '暂无自定义 provider。',
configNoGroups: '未找到配置分组。',
configDiffPreviewCount: '配置差异预览({{count}}项)',
saveConfigFailed: '保存配置失败',
sourceAutonomy: 'autonomy',
sourceDirect: 'direct',
sourceMemoryTodo: 'memory_todo',
statusRunning: 'running',
statusWaiting: 'waiting',
statusBlocked: 'blocked',
statusSuccess: 'success',
statusError: 'error',
statusSuppressed: 'suppressed',
taskId: '任务 ID',
inputPreview: '输入预览',
blockReason: '阻断原因',
actionFailed: '操作失败',
cronExpressionPlaceholder: '*/5 * * * *',
recipientId: '接收者 ID',
configLabels: {
gateway: '网关',
host: '主机',
@@ -342,6 +568,8 @@ const resources = {
google_maps: '谷歌地图',
url_context: 'URL 上下文',
api_base: 'API 基础地址',
protocol: '协议',
models: '模型列表',
organization: '组织',
project: '项目',
region: '区域',

View File

@@ -33,15 +33,15 @@ const Chat: React.FC = () => {
else if (baseRole === 'system') role = 'system';
let text = m.content || '';
let label = role === 'user' ? 'User' : role === 'tool' ? 'Exec' : role === 'system' ? 'System' : 'Agent';
let label = role === 'user' ? t('user') : role === 'tool' ? t('exec') : role === 'system' ? t('system') : t('agent');
if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
role = 'exec';
label = 'Exec';
label = t('exec');
text = `${text}\n[tool calls: ${m.tool_calls.map((x: any) => x?.function?.name || x?.name).filter(Boolean).join(', ')}]`;
}
if (baseRole === 'tool') {
text = `[tool output]\n${text}`;
text = `[${t('toolOutput')}]\n${text}`;
}
return { role, text, label };
});
@@ -69,7 +69,7 @@ const Chat: React.FC = () => {
}
const userText = msg + (media ? `\n[Attached File: ${f?.name}]` : '');
setChat((prev) => [...prev, { role: 'user', text: userText, label: 'User' }]);
setChat((prev) => [...prev, { role: 'user', text: userText, label: t('user') }]);
const currentMsg = msg;
setMsg('');
@@ -89,7 +89,7 @@ const Chat: React.FC = () => {
const decoder = new TextDecoder();
let assistantText = '';
setChat((prev) => [...prev, { role: 'assistant', text: '', label: 'Agent' }]);
setChat((prev) => [...prev, { role: 'assistant', text: '', label: t('agent') }]);
while (true) {
const { value, done } = await reader.read();
@@ -98,7 +98,7 @@ const Chat: React.FC = () => {
assistantText += chunk;
setChat((prev) => {
const next = [...prev];
next[next.length - 1] = { role: 'assistant', text: assistantText, label: 'Agent' };
next[next.length - 1] = { role: 'assistant', text: assistantText, label: t('agent') };
return next;
});
}
@@ -106,7 +106,7 @@ const Chat: React.FC = () => {
// refresh full persisted history (includes tool/internal traces)
loadHistory();
} catch (e) {
setChat((prev) => [...prev, { role: 'system', text: 'Error: Failed to get response from server.', label: 'System' }]);
setChat((prev) => [...prev, { role: 'system', text: t('chatServerError'), label: t('system') }]);
}
}
@@ -126,12 +126,12 @@ const Chat: React.FC = () => {
<div className="flex-1 flex flex-col bg-zinc-950/50">
<div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<h2 className="text-sm text-zinc-300 font-medium">Session</h2>
<h2 className="text-sm text-zinc-300 font-medium">{t('session')}</h2>
<select value={sessionKey} onChange={(e)=>setSessionKey(e.target.value)} className="bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200">
{(sessions || []).map((s:any)=> <option key={s.key} value={s.key}>{s.key}</option>)}
</select>
</div>
<button onClick={loadHistory} className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-zinc-800 hover:bg-zinc-700"><RefreshCw className="w-3 h-3"/>Reload History</button>
<button onClick={loadHistory} className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-zinc-800 hover:bg-zinc-700"><RefreshCw className="w-3 h-3"/>{t('reloadHistory')}</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
@@ -173,7 +173,7 @@ const Chat: React.FC = () => {
<div className={`flex items-start gap-2 max-w-[96%] ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
<div className={`w-7 h-7 mt-1 rounded-full text-xs font-bold flex items-center justify-center shrink-0 ${avatarClass}`}>{avatar}</div>
<div className={`max-w-[92%] rounded-2xl px-4 py-3 shadow-sm ${bubbleClass}`}>
<div className="text-[11px] opacity-80 mb-1">{m.label || (isUser ? 'User' : isExec ? 'Exec' : isSystem ? 'System' : 'Agent')}</div>
<div className="text-[11px] opacity-80 mb-1">{m.label || (isUser ? t('user') : isExec ? t('exec') : isSystem ? t('system') : t('agent'))}</div>
<p className="whitespace-pre-wrap text-[14px] leading-relaxed">{m.text}</p>
</div>
</div>

View File

@@ -135,7 +135,7 @@ const Config: React.FC = () => {
setBaseline(JSON.parse(JSON.stringify(payload)));
setShowDiff(false);
} catch (e) {
alert('Failed to save config: ' + e);
alert(`${t('saveConfigFailed')}: ${e}`);
}
}
@@ -154,15 +154,15 @@ const Config: React.FC = () => {
<button onClick={async () => { await loadConfig(); setTimeout(() => setBaseline(JSON.parse(JSON.stringify(cfg))), 0); }} className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm font-medium transition-colors">
<RefreshCw className="w-4 h-4" /> {t('reload')}
</button>
<button onClick={() => setShowDiff(true)} className="px-3 py-2 bg-zinc-900 border border-zinc-800 rounded-lg text-sm"></button>
<button onClick={() => setShowDiff(true)} className="px-3 py-2 bg-zinc-900 border border-zinc-800 rounded-lg text-sm">{t('configDiffPreview')}</button>
<button onClick={() => setBasicMode(v => !v)} className="px-3 py-2 bg-zinc-900 border border-zinc-800 rounded-lg text-sm">
{basicMode ? '基础模式' : '高级模式'}
{basicMode ? t('configBasicMode') : t('configAdvancedMode')}
</button>
<label className="flex items-center gap-2 text-sm text-zinc-300">
<input type="checkbox" checked={hotOnly} onChange={(e) => setHotOnly(e.target.checked)} />
{t('configHotOnly')}
</label>
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索分类..." className="px-3 py-2 bg-zinc-950 border border-zinc-800 rounded-lg text-sm" />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t('configSearchPlaceholder')} className="px-3 py-2 bg-zinc-950 border border-zinc-800 rounded-lg text-sm" />
</div>
<button onClick={saveConfig} className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-colors shadow-sm">
<Save className="w-4 h-4" /> {t('saveChanges')}
@@ -170,7 +170,7 @@ const Config: React.FC = () => {
</div>
<div className="bg-zinc-900/40 border border-zinc-800/80 rounded-2xl p-4">
<div className="text-sm font-semibold text-zinc-300 mb-2"></div>
<div className="text-sm font-semibold text-zinc-300 mb-2">{t('configHotFieldsFull')}</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
{hotReloadFieldDetails.map((it) => (
<div key={it.path} className="p-2 rounded bg-zinc-950 border border-zinc-800">
@@ -185,7 +185,7 @@ const Config: React.FC = () => {
{!showRaw ? (
<div className="flex-1 flex min-h-0">
<aside className="w-44 md:w-56 border-r border-zinc-800 bg-zinc-950/40 p-2 md:p-3 overflow-y-auto shrink-0">
<div className="text-xs text-zinc-500 uppercase tracking-widest mb-2 px-2">Top Level</div>
<div className="text-xs text-zinc-500 uppercase tracking-widest mb-2 px-2">{t('configTopLevel')}</div>
<div className="space-y-1">
{filteredTopKeys.map((k) => (
<button
@@ -203,25 +203,25 @@ const Config: React.FC = () => {
{activeTop === 'providers' && !showRaw && (
<div className="rounded-xl border border-zinc-800 bg-zinc-950/40 p-3 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-semibold text-zinc-200">Proxies</div>
<div className="text-sm font-semibold text-zinc-200">{t('configProxies')}</div>
<div className="flex items-center gap-2">
<input value={newProxyName} onChange={(e)=>setNewProxyName(e.target.value)} placeholder="new provider name" className="px-2 py-1 rounded bg-zinc-900 border border-zinc-700 text-xs" />
<button onClick={addProxy} className="px-2 py-1 rounded bg-indigo-600 hover:bg-indigo-500 text-xs">Add</button>
<input value={newProxyName} onChange={(e)=>setNewProxyName(e.target.value)} placeholder={t('configNewProviderName')} className="px-2 py-1 rounded bg-zinc-900 border border-zinc-700 text-xs" />
<button onClick={addProxy} className="px-2 py-1 rounded bg-indigo-600 hover:bg-indigo-500 text-xs">{t('add')}</button>
</div>
</div>
<div className="space-y-2">
{Object.entries(((cfg as any)?.providers?.proxies || {}) as Record<string, any>).map(([name, p]) => (
<div key={name} className="grid grid-cols-1 md:grid-cols-8 gap-2 rounded-lg border border-zinc-800 bg-zinc-900/40 p-2 text-xs">
<div className="md:col-span-1 font-mono text-zinc-300 flex items-center">{name}</div>
<input value={String(p?.api_base || '')} onChange={(e)=>updateProxyField(name, 'api_base', e.target.value)} placeholder="api_base" className="md:col-span-2 px-2 py-1 rounded bg-zinc-950 border border-zinc-800" />
<input value={String(p?.api_key || '')} onChange={(e)=>updateProxyField(name, 'api_key', e.target.value)} placeholder="api_key" className="md:col-span-2 px-2 py-1 rounded bg-zinc-950 border border-zinc-800" />
<input value={String(p?.protocol || '')} onChange={(e)=>updateProxyField(name, 'protocol', e.target.value)} placeholder="protocol" className="md:col-span-1 px-2 py-1 rounded bg-zinc-950 border border-zinc-800" />
<input value={Array.isArray(p?.models) ? p.models.join(',') : ''} onChange={(e)=>updateProxyField(name, 'models', e.target.value.split(',').map(s=>s.trim()).filter(Boolean))} placeholder="models,a,b" className="md:col-span-1 px-2 py-1 rounded bg-zinc-950 border border-zinc-800" />
<button onClick={()=>removeProxy(name)} className="md:col-span-1 px-2 py-1 rounded bg-red-900/60 hover:bg-red-800 text-red-100">Delete</button>
<input value={String(p?.api_base || '')} onChange={(e)=>updateProxyField(name, 'api_base', e.target.value)} placeholder={t('configLabels.api_base')} className="md:col-span-2 px-2 py-1 rounded bg-zinc-950 border border-zinc-800" />
<input value={String(p?.api_key || '')} onChange={(e)=>updateProxyField(name, 'api_key', e.target.value)} placeholder={t('configLabels.api_key')} className="md:col-span-2 px-2 py-1 rounded bg-zinc-950 border border-zinc-800" />
<input value={String(p?.protocol || '')} onChange={(e)=>updateProxyField(name, 'protocol', e.target.value)} placeholder={t('configLabels.protocol')} className="md:col-span-1 px-2 py-1 rounded bg-zinc-950 border border-zinc-800" />
<input value={Array.isArray(p?.models) ? p.models.join(',') : ''} onChange={(e)=>updateProxyField(name, 'models', e.target.value.split(',').map(s=>s.trim()).filter(Boolean))} placeholder={`${t('configLabels.models')},a,b`} className="md:col-span-1 px-2 py-1 rounded bg-zinc-950 border border-zinc-800" />
<button onClick={()=>removeProxy(name)} className="md:col-span-1 px-2 py-1 rounded bg-red-900/60 hover:bg-red-800 text-red-100">{t('delete')}</button>
</div>
))}
{Object.keys(((cfg as any)?.providers?.proxies || {}) as Record<string, any>).length === 0 && (
<div className="text-xs text-zinc-500">No custom providers yet.</div>
<div className="text-xs text-zinc-500">{t('configNoCustomProviders')}</div>
)}
</div>
</div>
@@ -236,7 +236,7 @@ const Config: React.FC = () => {
onChange={(path, val) => setCfg(v => setPath(v, path, val))}
/>
) : (
<div className="text-zinc-500 text-sm">No config groups found.</div>
<div className="text-zinc-500 text-sm">{t('configNoGroups')}</div>
)}
</div>
</div>
@@ -254,16 +254,16 @@ const Config: React.FC = () => {
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4">
<div className="w-full max-w-4xl max-h-[85vh] bg-zinc-950 border border-zinc-800 rounded-2xl overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between">
<div className="font-semibold">{diffRows.length}</div>
<button className="px-3 py-1 rounded bg-zinc-800" onClick={() => setShowDiff(false)}></button>
<div className="font-semibold">{t('configDiffPreviewCount', { count: diffRows.length })}</div>
<button className="px-3 py-1 rounded bg-zinc-800" onClick={() => setShowDiff(false)}>{t('close')}</button>
</div>
<div className="overflow-auto text-xs">
<table className="w-full">
<thead className="sticky top-0 bg-zinc-900 text-zinc-300">
<tr>
<th className="text-left p-2">Path</th>
<th className="text-left p-2">Before</th>
<th className="text-left p-2">After</th>
<th className="text-left p-2">{t('path')}</th>
<th className="text-left p-2">{t('before')}</th>
<th className="text-left p-2">{t('after')}</th>
</tr>
</thead>
<tbody>

View File

@@ -134,10 +134,10 @@ const Cron: React.FC = () => {
await refreshCron();
} else {
const err = await r.text();
alert('Action failed: ' + err);
alert(`${t('actionFailed')}: ${err}`);
}
} catch (e) {
alert('Action failed: ' + e);
alert(`${t('actionFailed')}: ${e}`);
}
}
@@ -164,7 +164,7 @@ const Cron: React.FC = () => {
<div>
<h3 className="font-semibold text-zinc-100 mb-1">{j.name || j.id}</h3>
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-zinc-500 uppercase tracking-wider bg-zinc-800/50 px-2 py-0.5 rounded">ID: {j.id.slice(-6)}</span>
<span className="text-[10px] font-mono text-zinc-500 uppercase tracking-wider bg-zinc-800/50 px-2 py-0.5 rounded">{t('id')}: {j.id.slice(-6)}</span>
</div>
</div>
{j.enabled ? (
@@ -259,7 +259,7 @@ const Cron: React.FC = () => {
type="text"
value={cronForm.expr}
onChange={(e) => setCronForm({ ...cronForm, expr: e.target.value })}
placeholder="*/5 * * * *"
placeholder={t('cronExpressionPlaceholder')}
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors"
/>
</label>
@@ -310,7 +310,7 @@ const Cron: React.FC = () => {
type="text"
value={cronForm.to}
onChange={(e) => setCronForm({ ...cronForm, to: e.target.value })}
placeholder="recipient id"
placeholder={t('recipientId')}
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors"
/>
)}

View File

@@ -22,7 +22,7 @@ const Dashboard: React.FC = () => {
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h1 className="text-xl md:text-2xl font-semibold tracking-tight">{t('dashboard')}</h1>
<div className="mt-1 text-xs text-zinc-500">Gateway: {gatewayVersion} · WebUI: {webuiVersion}</div>
<div className="mt-1 text-xs text-zinc-500">{t('gateway')}: {gatewayVersion} · {t('webui')}: {webuiVersion}</div>
</div>
<button onClick={refreshAll} className="flex items-center gap-2 px-3 md:px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm font-medium transition-colors shrink-0">
<RefreshCw className="w-4 h-4" /> {t('refreshAll')}

View File

@@ -51,7 +51,7 @@ const EKG: React.FC = () => {
return (
<div className="h-full p-4 md:p-6 flex flex-col gap-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h1 className="text-xl md:text-2xl font-semibold">EKG</h1>
<h1 className="text-xl md:text-2xl font-semibold">{t('ekg')}</h1>
<div className="flex items-center gap-2">
<select value={ekgWindow} onChange={(e)=>setEkgWindow(e.target.value as '6h' | '24h' | '7d')} className="bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-xs">
<option value="6h">6h</option>

View File

@@ -46,7 +46,7 @@ const LogCodes: React.FC = () => {
<input
value={kw}
onChange={(e) => setKw(e.target.value)}
placeholder="Search code/text"
placeholder={t('logCodesSearchPlaceholder')}
className="w-72 bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
@@ -55,8 +55,8 @@ const LogCodes: React.FC = () => {
<table className="w-full text-sm">
<thead className="bg-zinc-900/90 border-b border-zinc-800">
<tr className="text-zinc-400">
<th className="text-left p-3 font-medium w-40">Code</th>
<th className="text-left p-3 font-medium">Template</th>
<th className="text-left p-3 font-medium w-40">{t('code')}</th>
<th className="text-left p-3 font-medium">{t('template')}</th>
</tr>
</thead>
<tbody>
@@ -68,7 +68,7 @@ const LogCodes: React.FC = () => {
))}
{filtered.length === 0 && (
<tr>
<td className="p-6 text-zinc-500" colSpan={2}>No codes</td>
<td className="p-6 text-zinc-500" colSpan={2}>{t('logCodesNoCodes')}</td>
</tr>
)}
</tbody>

View File

@@ -174,7 +174,7 @@ const Logs: React.FC = () => {
isStreaming ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' : 'bg-zinc-800 text-zinc-500 border-zinc-700'
}`}>
<div className={`w-1.5 h-1.5 rounded-full ${isStreaming ? 'bg-emerald-500 animate-pulse' : 'bg-zinc-600'}`} />
{isStreaming ? 'Live' : 'Paused'}
{isStreaming ? t('live') : t('paused')}
</div>
</div>
<div className="flex items-center gap-2">
@@ -182,7 +182,7 @@ const Logs: React.FC = () => {
onClick={() => setShowRaw(!showRaw)}
className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm font-medium transition-colors text-zinc-300"
>
{showRaw ? 'Pretty' : 'Raw'}
{showRaw ? t('pretty') : t('raw')}
</button>
<button
onClick={() => setIsStreaming(!isStreaming)}
@@ -190,10 +190,10 @@ const Logs: React.FC = () => {
isStreaming ? 'bg-zinc-800 hover:bg-zinc-700 text-zinc-300' : 'bg-indigo-600 hover:bg-indigo-500 text-white'
}`}
>
{isStreaming ? <><Square className="w-4 h-4" /> Pause</> : <><Play className="w-4 h-4" /> Resume</>}
{isStreaming ? <><Square className="w-4 h-4" /> {t('pause')}</> : <><Play className="w-4 h-4" /> {t('resume')}</>}
</button>
<button onClick={clearLogs} className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm font-medium transition-colors text-zinc-300">
<Trash2 className="w-4 h-4" /> Clear
<Trash2 className="w-4 h-4" /> {t('clear')}
</button>
</div>
</div>
@@ -202,15 +202,15 @@ const Logs: React.FC = () => {
<div className="bg-zinc-900/50 px-4 py-2 border-b border-zinc-800 flex items-center justify-between">
<div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-zinc-500" />
<span className="text-xs font-mono text-zinc-500">system.log</span>
<span className="text-xs font-mono text-zinc-500">{t('systemLog')}</span>
</div>
<span className="text-[10px] font-mono text-zinc-600 uppercase tracking-widest">{logs.length} entries</span>
<span className="text-[10px] font-mono text-zinc-600 uppercase tracking-widest">{logs.length} {t('entries')}</span>
</div>
<div className="flex-1 overflow-auto selection:bg-indigo-500/30">
{logs.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-zinc-700 space-y-2 p-4">
<Terminal className="w-8 h-8 opacity-10" />
<p>Waiting for logs...</p>
<p>{t('waitingForLogs')}</p>
</div>
) : showRaw ? (
<div className="p-3 font-mono text-xs space-y-1">
@@ -223,11 +223,11 @@ const Logs: React.FC = () => {
<table className="w-full text-xs">
<thead className="sticky top-0 bg-zinc-900/95 border-b border-zinc-800">
<tr className="text-zinc-400">
<th className="text-left p-2 font-medium">Time</th>
<th className="text-left p-2 font-medium">Level</th>
<th className="text-left p-2 font-medium">Message</th>
<th className="text-left p-2 font-medium">Error</th>
<th className="text-left p-2 font-medium">Code/Caller</th>
<th className="text-left p-2 font-medium">{t('time')}</th>
<th className="text-left p-2 font-medium">{t('level')}</th>
<th className="text-left p-2 font-medium">{t('message')}</th>
<th className="text-left p-2 font-medium">{t('error')}</th>
<th className="text-left p-2 font-medium">{t('codeCaller')}</th>
</tr>
</thead>
<tbody>

View File

@@ -1,7 +1,9 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
const Memory: React.FC = () => {
const { t } = useTranslation();
const { q } = useAppContext();
const [files, setFiles] = useState<string[]>([]);
const [active, setActive] = useState('');
@@ -42,7 +44,7 @@ const Memory: React.FC = () => {
}
async function createFile() {
const name = prompt('memory file name', `note-${Date.now()}.md`);
const name = prompt(t('memoryFileNamePrompt'), `note-${Date.now()}.md`);
if (!name) return;
await fetch(`/webui/api/memory${q}`, {
method: 'POST',
@@ -61,7 +63,7 @@ const Memory: React.FC = () => {
<div className="flex h-full">
<aside className="w-72 border-r border-zinc-800 p-4 space-y-2 overflow-y-auto">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Memory Files</h2>
<h2 className="font-semibold">{t('memoryFiles')}</h2>
<button onClick={createFile} className="px-2 py-1 rounded bg-zinc-800">+</button>
</div>
{files.map((f) => (
@@ -73,8 +75,8 @@ const Memory: React.FC = () => {
</aside>
<main className="flex-1 p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-semibold">{active || 'No file selected'}</h2>
<button onClick={saveFile} className="px-3 py-1 rounded bg-indigo-600">Save</button>
<h2 className="font-semibold">{active || t('noFileSelected')}</h2>
<button onClick={saveFile} className="px-3 py-1 rounded bg-indigo-600">{t('save')}</button>
</div>
<textarea value={content} onChange={(e) => setContent(e.target.value)} className="w-full h-[80vh] bg-zinc-900 border border-zinc-800 rounded p-3" />
</main>

View File

@@ -36,12 +36,12 @@ const Nodes: React.FC = () => {
<div className="flex items-center gap-4">
<div className={`w-3 h-3 rounded-full ${node.online ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]'}`} />
<div>
<div className="font-semibold text-zinc-200">{node.name || node.id || `Node ${i + 1}`}</div>
<div className="font-semibold text-zinc-200">{node.name || node.id || `${t('node')} ${i + 1}`}</div>
<div className="text-xs text-zinc-500 font-mono mt-0.5">{node.id}</div>
</div>
</div>
<div className="text-right">
<div className="text-xs font-mono text-zinc-400 bg-zinc-800/50 px-2 py-1 rounded">{node.ip || 'Unknown IP'}</div>
<div className="text-xs font-mono text-zinc-400 bg-zinc-800/50 px-2 py-1 rounded">{node.ip || t('unknownIp')}</div>
<div className="text-[10px] text-zinc-600 mt-1 font-mono uppercase tracking-widest">v{node.version || '0.0.0'}</div>
</div>
</div>

View File

@@ -1,36 +1,26 @@
import React, { useState } from 'react';
import { Plus, RefreshCw, Trash2, Edit2, Zap, X, FileText, Save } from 'lucide-react';
import React, { useRef, useState } from 'react';
import { Plus, RefreshCw, Trash2, Zap, X, FileText, Save } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
import { Skill } from '../types';
const initialSkillForm: Omit<Skill, 'id'> = {
name: '',
description: '',
tools: [],
system_prompt: ''
};
const Skills: React.FC = () => {
const { t } = useTranslation();
const { skills, refreshSkills, q } = useAppContext();
const { skills, refreshSkills, q, clawhubInstalled, clawhubPath } = useAppContext();
const ui = useUI();
const [installName, setInstallName] = useState('');
const qp = (k: string, v: string) => `${q}${q ? '&' : '?'}${k}=${encodeURIComponent(v)}`;
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
const [form, setForm] = useState<Omit<Skill, 'id'>>(initialSkillForm);
const [isFileModalOpen, setIsFileModalOpen] = useState(false);
const [activeSkill, setActiveSkill] = useState<string>('');
const [skillFiles, setSkillFiles] = useState<string[]>([]);
const [activeFile, setActiveFile] = useState<string>('');
const [fileContent, setFileContent] = useState('');
const uploadRef = useRef<HTMLInputElement>(null);
async function deleteSkill(id: string) {
if (!await ui.confirmDialog({ title: 'Delete Skill', message: 'Are you sure you want to delete this skill?', danger: true, confirmText: 'Delete' })) return;
if (!await ui.confirmDialog({ title: t('skillsDeleteTitle'), message: t('skillsDeleteMessage'), danger: true, confirmText: t('delete') })) return;
try {
await fetch(`/webui/api/skills${qp('id', id)}`, { method: 'DELETE' });
await refreshSkills();
@@ -39,39 +29,95 @@ const Skills: React.FC = () => {
}
}
async function installClawHubIfNeeded() {
if (clawhubInstalled) return true;
const confirm = await ui.confirmDialog({
title: t('skillsClawhubMissingTitle'),
message: t('skillsClawhubMissingMessage'),
confirmText: t('skillsInstallNow')
});
if (!confirm) return false;
ui.showLoading(t('skillsInstallingDeps'));
try {
const r = await fetch(`/webui/api/skills${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'install_clawhub' }),
});
const text = await r.text();
if (!r.ok) {
await ui.notify({ title: t('skillsInstallFailedTitle'), message: text || t('skillsInstallFailedMessage') });
return false;
}
await ui.notify({ title: t('skillsInstallDoneTitle'), message: t('skillsInstallDoneMessage') });
await refreshSkills();
return true;
} finally {
ui.hideLoading();
}
}
async function installSkill() {
const name = installName.trim();
if (!name) return;
const ready = await installClawHubIfNeeded();
if (!ready) return;
const r = await fetch(`/webui/api/skills${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'install', name }),
});
if (!r.ok) {
await ui.notify({ title: 'Request Failed', message: await r.text() });
await ui.notify({ title: t('requestFailed'), message: await r.text() });
return;
}
setInstallName('');
await refreshSkills();
}
async function handleSubmit() {
async function onAddSkillClick() {
const yes = await ui.confirmDialog({
title: t('skillsAddTitle'),
message: t('skillsAddMessage'),
confirmText: t('skillsSelectArchive')
});
if (!yes) return;
uploadRef.current?.click();
}
async function onArchiveSelected(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
e.target.value = '';
if (!f) return;
const fd = new FormData();
fd.append('file', f);
ui.showLoading(t('skillsImporting'));
try {
const action = editingSkill ? 'update' : 'create';
const r = await fetch(`/webui/api/skills${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, ...(editingSkill && { id: editingSkill.id }), ...form })
body: fd,
});
if (r.ok) {
setIsModalOpen(false);
await refreshSkills();
} else {
await ui.notify({ title: 'Request Failed', message: await r.text() });
const text = await r.text();
if (!r.ok) {
await ui.notify({ title: t('skillsImportFailedTitle'), message: text || t('skillsImportFailedMessage') });
return;
}
} catch (e) {
await ui.notify({ title: 'Error', message: String(e) });
let imported: string[] = [];
try {
const j = JSON.parse(text);
imported = Array.isArray(j.imported) ? j.imported : [];
} catch {
imported = [];
}
await ui.notify({ title: t('skillsImportDoneTitle'), message: imported.length > 0 ? `${t('skillsImportedPrefix')}: ${imported.join(', ')}` : t('skillsImportDoneMessage') });
await refreshSkills();
} finally {
ui.hideLoading();
}
}
@@ -80,7 +126,7 @@ const Skills: React.FC = () => {
setIsFileModalOpen(true);
const r = await fetch(`/webui/api/skills${q ? `${q}&id=${encodeURIComponent(skillId)}&files=1` : `?id=${encodeURIComponent(skillId)}&files=1`}`);
if (!r.ok) {
await ui.notify({ title: 'Request Failed', message: await r.text() });
await ui.notify({ title: t('requestFailed'), message: await r.text() });
return;
}
const j = await r.json();
@@ -98,7 +144,7 @@ const Skills: React.FC = () => {
const url = `/webui/api/skills${q ? `${q}&id=${encodeURIComponent(skillId)}&file=${encodeURIComponent(file)}` : `?id=${encodeURIComponent(skillId)}&file=${encodeURIComponent(file)}`}`;
const r = await fetch(url);
if (!r.ok) {
await ui.notify({ title: 'Request Failed', message: await r.text() });
await ui.notify({ title: t('requestFailed'), message: await r.text() });
return;
}
const j = await r.json();
@@ -114,26 +160,31 @@ const Skills: React.FC = () => {
body: JSON.stringify({ action: 'write_file', id: activeSkill, file: activeFile, content: fileContent }),
});
if (!r.ok) {
await ui.notify({ title: 'Request Failed', message: await r.text() });
await ui.notify({ title: t('requestFailed'), message: await r.text() });
return;
}
await ui.notify({ title: 'Saved', message: 'Skill file saved successfully.' });
await ui.notify({ title: t('saved'), message: t('skillsFileSaved') });
}
return (
<div className="p-8 max-w-7xl mx-auto space-y-8">
<input ref={uploadRef} type="file" accept=".zip,.tar,.tar.gz,.tgz" className="hidden" onChange={onArchiveSelected} />
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="text-2xl font-semibold tracking-tight">{t('skills')}</h1>
<div className="flex items-center gap-2">
<input value={installName} onChange={(e) => setInstallName(e.target.value)} placeholder="skill name" className="px-3 py-2 bg-zinc-950 border border-zinc-800 rounded-lg text-sm" />
<button onClick={installSkill} className="px-3 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium">Install</button>
<input value={installName} onChange={(e) => setInstallName(e.target.value)} placeholder={t('skillsNamePlaceholder')} className="px-3 py-2 bg-zinc-950 border border-zinc-800 rounded-lg text-sm" />
<button onClick={installSkill} className="px-3 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium">{t('install')}</button>
</div>
<div className="flex items-center gap-3">
<div className={`text-xs px-2 py-1 rounded-md border ${clawhubInstalled ? 'text-emerald-300 border-emerald-700/50 bg-emerald-900/20' : 'text-amber-300 border-amber-700/50 bg-amber-900/20'}`} title={clawhubPath || t('skillsClawhubNotFound')}>
{t('skillsClawhubStatus')}: {clawhubInstalled ? t('installed') : t('notInstalled')}
</div>
<button onClick={() => refreshSkills()} className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm font-medium transition-colors">
<RefreshCw className="w-4 h-4" /> {t('refresh')}
</button>
<button onClick={() => { setEditingSkill(null); setForm(initialSkillForm); setIsModalOpen(true); }} className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-colors shadow-sm">
<Plus className="w-4 h-4" /> Add Skill
<button onClick={onAddSkillClick} className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-colors shadow-sm">
<Plus className="w-4 h-4" /> {t('skillsAdd')}
</button>
</div>
</div>
@@ -148,47 +199,32 @@ const Skills: React.FC = () => {
</div>
<div>
<h3 className="font-semibold text-zinc-100">{s.name}</h3>
<span className="text-[10px] font-mono text-zinc-500 uppercase tracking-widest">ID: {s.id.slice(-6)}</span>
<span className="text-[10px] font-mono text-zinc-500 uppercase tracking-widest">{t('id')}: {s.id.slice(-6)}</span>
</div>
</div>
</div>
<p className="text-sm text-zinc-400 mb-6 line-clamp-2">{s.description || 'No description provided.'}</p>
<p className="text-sm text-zinc-400 mb-6 line-clamp-2">{s.description || t('noDescription')}</p>
<div className="space-y-4 mb-6">
<div>
<div className="text-[10px] text-zinc-500 uppercase tracking-widest mb-2">Tools</div>
<div className="text-[10px] text-zinc-500 uppercase tracking-widest mb-2">{t('tools')}</div>
<div className="flex flex-wrap gap-2">
{(Array.isArray(s.tools) ? s.tools : []).map(tool => (
<span key={tool} className="px-2 py-1 bg-zinc-800/50 text-zinc-300 text-[10px] font-mono rounded border border-zinc-700/50">{tool}</span>
))}
{(!Array.isArray(s.tools) || s.tools.length === 0) && <span className="text-xs text-zinc-600 italic">No tools defined</span>}
{(!Array.isArray(s.tools) || s.tools.length === 0) && <span className="text-xs text-zinc-600 italic">{t('skillsNoTools')}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-2 pt-4 border-t border-zinc-800/50 mt-auto">
<button
onClick={() => {
setEditingSkill(s);
setForm({
name: s.name,
description: s.description,
tools: Array.isArray(s.tools) ? s.tools : [],
system_prompt: s.system_prompt || ''
});
setIsModalOpen(true);
}}
className="flex-1 flex items-center justify-center gap-2 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-xs font-medium transition-colors text-zinc-300"
>
<Edit2 className="w-3.5 h-3.5" /> Edit Skill
</button>
<button
onClick={() => openFileManager(s.id)}
className="p-2 bg-indigo-500/10 text-indigo-400 hover:bg-indigo-500/20 rounded-lg transition-colors"
title="Files"
className="flex-1 flex items-center justify-center gap-2 py-2 bg-indigo-500/10 text-indigo-300 hover:bg-indigo-500/20 rounded-lg text-xs font-medium transition-colors"
title={t('files')}
>
<FileText className="w-4 h-4" />
<FileText className="w-4 h-4" /> {t('skillsFileEdit')}
</button>
<button
onClick={() => deleteSkill(s.id)}
@@ -201,56 +237,13 @@ const Skills: React.FC = () => {
))}
</div>
<AnimatePresence>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setIsModalOpen(false)} className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} className="relative w-full max-w-2xl bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden">
<div className="p-6 border-b border-zinc-800 flex items-center justify-between">
<h2 className="text-xl font-semibold">{editingSkill ? 'Edit Skill' : 'Add Skill'}</h2>
<button onClick={() => setIsModalOpen(false)} className="p-2 hover:bg-zinc-800 rounded-full transition-colors text-zinc-400"><X className="w-5 h-5" /></button>
</div>
<div className="p-6 space-y-4 max-h-[70vh] overflow-y-auto">
<label className="block">
<span className="text-sm font-medium text-zinc-400 mb-1.5 block">Name</span>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm focus:border-indigo-500 outline-none" />
</label>
<label className="block">
<span className="text-sm font-medium text-zinc-400 mb-1.5 block">Description</span>
<input type="text" value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm focus:border-indigo-500 outline-none" />
</label>
<label className="block">
<span className="text-sm font-medium text-zinc-400 mb-1.5 block">Tools (Comma separated)</span>
<input
type="text"
value={form.tools.join(', ')}
onChange={e => setForm({ ...form, tools: e.target.value.split(',').map(t => t.trim()).filter(t => t) })}
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm font-mono focus:border-indigo-500 outline-none"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-zinc-400 mb-1.5 block">System Prompt</span>
<textarea value={form.system_prompt} onChange={e => setForm({ ...form, system_prompt: e.target.value })} rows={6} className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm focus:border-indigo-500 outline-none resize-none" />
</label>
</div>
<div className="p-6 border-t border-zinc-800 bg-zinc-900/50 flex justify-end gap-3">
<button onClick={() => setIsModalOpen(false)} className="px-4 py-2 text-sm text-zinc-400">Cancel</button>
<button onClick={handleSubmit} className="px-6 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-sm font-medium shadow-lg shadow-indigo-600/20">
{editingSkill ? 'Update' : 'Create'}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
<AnimatePresence>
{isFileModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setIsFileModalOpen(false)} className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<motion.div initial={{ opacity: 0, scale: 0.96 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.96 }} className="relative w-full max-w-6xl h-[80vh] bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden flex">
<aside className="w-72 border-r border-zinc-800 bg-zinc-950/60 p-3 overflow-y-auto">
<div className="text-sm font-semibold mb-3">{activeSkill} files</div>
<div className="text-sm font-semibold mb-3">{activeSkill} {t('files')}</div>
<div className="space-y-1">
{skillFiles.map(f => (
<button key={f} onClick={() => openFile(activeSkill, f)} className={`w-full text-left px-2 py-1.5 rounded text-xs font-mono ${activeFile===f ? 'bg-indigo-500/20 text-indigo-200' : 'text-zinc-300 hover:bg-zinc-800'}`}>{f}</button>
@@ -259,9 +252,9 @@ const Skills: React.FC = () => {
</aside>
<main className="flex-1 flex flex-col">
<div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between">
<div className="text-sm text-zinc-300 font-mono truncate">{activeFile || '(no file selected)'}</div>
<div className="text-sm text-zinc-300 font-mono truncate">{activeFile || t('noFileSelected')}</div>
<div className="flex items-center gap-2">
<button onClick={saveFile} className="px-3 py-1.5 rounded bg-emerald-600 hover:bg-emerald-500 text-white text-xs flex items-center gap-1"><Save className="w-3 h-3"/>Save</button>
<button onClick={saveFile} className="px-3 py-1.5 rounded bg-emerald-600 hover:bg-emerald-500 text-white text-xs flex items-center gap-1"><Save className="w-3 h-3"/>{t('save')}</button>
<button onClick={() => setIsFileModalOpen(false)} className="p-2 hover:bg-zinc-800 rounded-full transition-colors text-zinc-400"><X className="w-4 h-4" /></button>
</div>
</div>

View File

@@ -109,19 +109,19 @@ const TaskAudit: React.FC = () => {
<div className="flex items-center gap-2">
<select value={sourceFilter} onChange={(e)=>setSourceFilter(e.target.value)} className="bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-xs">
<option value="all">{t('allSources')}</option>
<option value="autonomy">autonomy</option>
<option value="direct">direct</option>
<option value="memory_todo">memory_todo</option>
<option value="autonomy">{t('sourceAutonomy')}</option>
<option value="direct">{t('sourceDirect')}</option>
<option value="memory_todo">{t('sourceMemoryTodo')}</option>
<option value="-">-</option>
</select>
<select value={statusFilter} onChange={(e)=>setStatusFilter(e.target.value)} className="bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-xs">
<option value="all">{t('allStatus')}</option>
<option value="running">running</option>
<option value="waiting">waiting</option>
<option value="blocked">blocked</option>
<option value="success">success</option>
<option value="error">error</option>
<option value="suppressed">suppressed</option>
<option value="running">{t('statusRunning')}</option>
<option value="waiting">{t('statusWaiting')}</option>
<option value="blocked">{t('statusBlocked')}</option>
<option value="success">{t('statusSuccess')}</option>
<option value="error">{t('statusError')}</option>
<option value="suppressed">{t('statusSuppressed')}</option>
</select>
<button onClick={fetchData} className="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm">{loading ? t('loading') : t('refresh')}</button>
</div>
@@ -132,7 +132,7 @@ const TaskAudit: React.FC = () => {
<div className="text-xs text-zinc-400 uppercase tracking-wider">{t('dailySummary')}</div>
<div className="flex items-center gap-2">
<input type="date" value={reportDate} onChange={(e)=>setReportDate(e.target.value)} className="px-2 py-1 rounded bg-zinc-900 border border-zinc-700 text-xs" />
<button onClick={() => setShowDailyReport(v => !v)} className="px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-xs">{showDailyReport ? 'Hide' : 'Show'}</button>
<button onClick={() => setShowDailyReport(v => !v)} className="px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-xs">{showDailyReport ? t('hide') : t('show')}</button>
<button onClick={exportDailyReport} className="px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-xs">{t('export')}</button>
</div>
</div>
@@ -180,19 +180,19 @@ const TaskAudit: React.FC = () => {
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<div><div className="text-zinc-500 text-xs">Task ID</div><div className="font-mono break-all">{selected.task_id}</div></div>
<div><div className="text-zinc-500 text-xs">Status</div><div>{selected.status}</div></div>
<div><div className="text-zinc-500 text-xs">Source</div><div>{selected.source || '-'}</div></div>
<div><div className="text-zinc-500 text-xs">Duration</div><div>{selected.duration_ms || 0}ms</div></div>
<div><div className="text-zinc-500 text-xs">Channel</div><div>{selected.channel}</div></div>
<div><div className="text-zinc-500 text-xs">Session</div><div className="font-mono break-all">{selected.session}</div></div>
<div><div className="text-zinc-500 text-xs">Provider</div><div>{selected.provider || '-'}</div></div>
<div><div className="text-zinc-500 text-xs">Model</div><div>{selected.model || '-'}</div></div>
<div><div className="text-zinc-500 text-xs">Time</div><div>{selected.time}</div></div>
<div><div className="text-zinc-500 text-xs">{t('taskId')}</div><div className="font-mono break-all">{selected.task_id}</div></div>
<div><div className="text-zinc-500 text-xs">{t('status')}</div><div>{selected.status}</div></div>
<div><div className="text-zinc-500 text-xs">{t('source')}</div><div>{selected.source || '-'}</div></div>
<div><div className="text-zinc-500 text-xs">{t('duration')}</div><div>{selected.duration_ms || 0}ms</div></div>
<div><div className="text-zinc-500 text-xs">{t('channel')}</div><div>{selected.channel}</div></div>
<div><div className="text-zinc-500 text-xs">{t('session')}</div><div className="font-mono break-all">{selected.session}</div></div>
<div><div className="text-zinc-500 text-xs">{t('provider')}</div><div>{selected.provider || '-'}</div></div>
<div><div className="text-zinc-500 text-xs">{t('model')}</div><div>{selected.model || '-'}</div></div>
<div><div className="text-zinc-500 text-xs">{t('time')}</div><div>{selected.time}</div></div>
</div>
<div>
<div className="text-zinc-500 text-xs mb-1">Input Preview</div>
<div className="text-zinc-500 text-xs mb-1">{t('inputPreview')}</div>
<div className="p-2 rounded bg-zinc-950/60 border border-zinc-800 whitespace-pre-wrap">{selected.input_preview || '-'}</div>
</div>
@@ -202,7 +202,7 @@ const TaskAudit: React.FC = () => {
</div>
<div>
<div className="text-zinc-500 text-xs mb-1">Block Reason</div>
<div className="text-zinc-500 text-xs mb-1">{t('blockReason')}</div>
<div className="p-2 rounded bg-zinc-950/60 border border-zinc-800 whitespace-pre-wrap text-amber-200">{selected.block_reason || '-'}</div>
</div>

View File

@@ -69,8 +69,8 @@ const Tasks: React.FC = () => {
<div className="border border-zinc-800 rounded-xl bg-zinc-900/40 p-4 space-y-3">
<div className="text-xs text-zinc-400 uppercase tracking-wider">{t('taskCrud')}</div>
<input value={draft.id || ''} onChange={(e)=>setDraft({ ...draft, id: e.target.value })} placeholder="id" className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded" />
<textarea value={draft.content || ''} onChange={(e)=>setDraft({ ...draft, content: e.target.value })} placeholder="content" className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded min-h-[120px]" />
<input value={draft.id || ''} onChange={(e)=>setDraft({ ...draft, id: e.target.value })} placeholder={t('id')} className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded" />
<textarea value={draft.content || ''} onChange={(e)=>setDraft({ ...draft, content: e.target.value })} placeholder={t('content')} className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded min-h-[120px]" />
<div className="flex items-center gap-2">
<button onClick={()=>save('create')} className="px-2 py-1 text-xs rounded bg-emerald-700/70 hover:bg-emerald-600">{t('createTask')}</button>