feat: 技能市场、工作空间文件树、模型配置引导、i18n精简

- 新增 ClawHub 技能市场:浏览、搜索、安装技能(npx clawhub install)
- 工作空间面板重写:递归显示完整文件树,支持默认应用打开文件
- 引导流程新增模型配置检测步骤,未配置时设置按钮红点提醒
- i18n 精简:仅保留 zh-CN 和 en 两种语言
- 修复会话列表空白闪烁、gateway 事件字段兼容等问题
This commit is contained in:
钟明原 committed 2026-03-17 13:05:23 +08:00
1 parent 623cf72555
commit 28e0503cbe
29 files changed
+1746 -2769

No files matched your search

+10 -6
View File
@@ -37,9 +37,11 @@
!macro customRemoveFiles
SetDetailsPrint both
DetailPrint "正在删除程序文件,请稍候..."
SetDetailsPrint none
RMDir /r $INSTDIR
SetDetailsPrint both
; 用 rd /s /q 替代 NSIS 逐文件删除,处理 node_modules 海量文件快几个量级
nsExec::ExecToStack 'cmd /c rd /s /q "$INSTDIR"'
Pop $0
; rd 只能删内容不删自身(如果当前目录在其中),兜底清理
RMDir $INSTDIR
DetailPrint "程序文件删除完成。"
!macroend
@@ -60,14 +62,16 @@
选择「否」则保留,重新安装后可自动恢复。" \
IDNO done
; 删除默认 userData 目录
RMDir /r "$APPDATA\easiest-claw-desktop"
; 删除默认 userData 目录(rd /s /q 比 RMDir /r 快很多)
nsExec::ExecToStack 'cmd /c rd /s /q "$APPDATA\easiest-claw-desktop"'
Pop $0
DetailPrint "默认用户数据已清除。"
; 删除自定义数据目录(从注册表读取)
ReadRegStr $0 HKCU "Software\EasiestClaw" "DataDir"
${If} $0 != ""
RMDir /r "$0"
nsExec::ExecToStack 'cmd /c rd /s /q "$0"'
Pop $1
DetailPrint "自定义数据目录已清除: $0"
${EndIf}
+135 -1
View File
@@ -1,7 +1,7 @@
import { randomBytes } from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import type { IpcMain } from 'electron'
import { shell, type IpcMain } from 'electron'
import { gw } from './gw'
// ── Workspace path cache (per agentId) ──────────────────────────────────────
@@ -104,6 +104,140 @@ export const registerAgentHandlers = (ipcMain: IpcMain): void => {
return gw('status', {})
})
// ── Full workspace tree scan (filesystem-based) ────────────────────────────
const SKIP_DIRS = new Set(['node_modules', '.git', '__pycache__', '.next', '.cache', '.venv', '.turbo', 'dist', '.DS_Store'])
const MAX_DEPTH = 5
const MAX_FILES = 2000
interface TreeNode {
name: string
type: 'file' | 'dir'
path: string
size?: number
updatedAtMs?: number
children?: TreeNode[]
}
async function scanDir(dir: string, basePath: string, depth: number, counter: { count: number }): Promise<TreeNode[]> {
if (depth > MAX_DEPTH || counter.count >= MAX_FILES) return []
let entries
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
return []
}
const dirs: TreeNode[] = []
const files: TreeNode[] = []
for (const entry of entries) {
if (counter.count >= MAX_FILES) break
if (entry.name.startsWith('.') && SKIP_DIRS.has(entry.name)) continue
if (SKIP_DIRS.has(entry.name)) continue
const fullPath = path.join(dir, entry.name)
const relativePath = path.join(basePath, entry.name).replace(/\\/g, '/')
if (entry.isDirectory()) {
const children = await scanDir(fullPath, relativePath, depth + 1, counter)
dirs.push({ name: entry.name, type: 'dir', path: relativePath, children })
} else if (entry.isFile()) {
counter.count++
try {
const stat = await fs.stat(fullPath)
files.push({ name: entry.name, type: 'file', path: relativePath, size: stat.size, updatedAtMs: stat.mtimeMs })
} catch {
files.push({ name: entry.name, type: 'file', path: relativePath })
}
}
}
// Sort: dirs first (alpha), then files (alpha)
dirs.sort((a, b) => a.name.localeCompare(b.name))
files.sort((a, b) => a.name.localeCompare(b.name))
return [...dirs, ...files]
}
ipcMain.handle('agents:workspace:tree', async (_event, params: { agentId: string }) => {
const workspace = await resolveWorkspaceDir(params.agentId)
if (!workspace) return { ok: false, error: 'Cannot resolve workspace path' }
try {
const realWorkspace = await fs.realpath(workspace).catch(() => workspace)
const tree = await scanDir(realWorkspace, '', 0, { count: 0 })
return { ok: true, workspace, tree }
} catch (err) {
return { ok: false, error: `Failed to scan workspace: ${(err as Error).message}` }
}
})
ipcMain.handle('agents:workspace:read', async (_event, params: { agentId: string; filePath: string }) => {
const workspace = await resolveWorkspaceDir(params.agentId)
if (!workspace) return { ok: false, error: 'Cannot resolve workspace path' }
const filePath = params.filePath
if (!filePath || filePath.includes('..') || path.isAbsolute(filePath)) {
return { ok: false, error: 'Invalid file path' }
}
const fullPath = path.join(workspace, filePath)
const realPath = await fs.realpath(fullPath).catch(() => fullPath)
const realWorkspace = await fs.realpath(workspace).catch(() => workspace)
if (!realPath.startsWith(realWorkspace)) {
return { ok: false, error: 'Path escapes workspace' }
}
try {
const stat = await fs.stat(fullPath)
if (stat.size > 1024 * 1024) {
return { ok: true, content: null, tooLarge: true }
}
// Binary detection: read first 512 bytes and check for NUL
const buf = Buffer.alloc(Math.min(512, stat.size))
const fh = await fs.open(fullPath, 'r')
try {
await fh.read(buf, 0, buf.length, 0)
} finally {
await fh.close()
}
if (buf.includes(0)) {
return { ok: true, content: null, binary: true }
}
const content = await fs.readFile(fullPath, 'utf8')
return { ok: true, content }
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return { ok: false, error: 'File not found' }
}
return { ok: false, error: `Failed to read file: ${(err as Error).message}` }
}
})
// ── Open file with system default application ──────────────────────────────
ipcMain.handle('agents:workspace:open', async (_event, params: { agentId: string; filePath: string }) => {
const workspace = await resolveWorkspaceDir(params.agentId)
if (!workspace) return { ok: false, error: 'Cannot resolve workspace path' }
const filePath = params.filePath
if (!filePath || filePath.includes('..') || path.isAbsolute(filePath)) {
return { ok: false, error: 'Invalid file path' }
}
const fullPath = path.join(workspace, filePath)
const realPath = await fs.realpath(fullPath).catch(() => fullPath)
const realWorkspace = await fs.realpath(workspace).catch(() => workspace)
if (!realPath.startsWith(realWorkspace)) {
return { ok: false, error: 'Path escapes workspace' }
}
const errorMessage = await shell.openPath(realPath)
if (errorMessage) {
return { ok: false, error: errorMessage }
}
return { ok: true }
})
// ── Memory directory listing (filesystem-based, gateway doesn't expose this) ─
ipcMain.handle('agents:memory:list', async (_event, params: { agentId: string }) => {
const workspace = await resolveWorkspaceDir(params.agentId)
+196
View File
@@ -1,8 +1,73 @@
import type { IpcMain } from 'electron'
import { net } from 'electron'
import { spawn } from 'node:child_process'
import os from 'node:os'
import path from 'node:path'
import { gw } from './gw'
import { isRecord, readOpenclawConfig as readConfig, writeOpenclawConfig as writeConfig } from '../lib/openclaw-config'
import { getBundledNpmBin } from '../lib/openclaw-paths'
const CLAWHUB_REGISTRY = process.env.CLAWHUB_REGISTRY || 'https://clawhub.ai'
// ── In-memory cache to avoid 429 rate limits ─────────────────────────────────
interface CacheEntry<T> { data: T; expiresAt: number }
const apiCache = new Map<string, CacheEntry<unknown>>()
function getCached<T>(key: string): T | undefined {
const entry = apiCache.get(key)
if (!entry) return undefined
if (Date.now() > entry.expiresAt) {
apiCache.delete(key)
return undefined
}
return entry.data as T
}
function setCache<T>(key: string, data: T, ttlMs: number): void {
apiCache.set(key, { data, expiresAt: Date.now() + ttlMs })
}
const CACHE_TTL_EXPLORE = 15 * 60_000 // 15 min for explore/browse
const CACHE_TTL_SEARCH = 5 * 60_000 // 5 min for search
/** Helper: make a JSON GET request to the ClawHub API */
async function clawHubGet<T>(path: string): Promise<T> {
const url = `${CLAWHUB_REGISTRY}${path}`
console.log(`[ClawHub] GET ${url}`)
return new Promise<T>((resolve, reject) => {
const request = net.request({ url, method: 'GET' })
request.setHeader('Accept', 'application/json')
request.setHeader('User-Agent', 'EasiestClaw-Desktop')
let body = ''
request.on('response', (response) => {
console.log(`[ClawHub] ${response.statusCode} ${url}`)
if (response.statusCode < 200 || response.statusCode >= 300) {
response.on('data', (chunk) => { body += chunk.toString() })
response.on('end', () => reject(new Error(`HTTP ${response.statusCode}: ${body}`)))
return
}
response.on('data', (chunk) => { body += chunk.toString() })
response.on('end', () => {
try { resolve(JSON.parse(body) as T) }
catch { reject(new Error('Invalid JSON response')) }
})
})
request.on('error', (err) => {
console.error(`[ClawHub] Error ${url}:`, err.message)
reject(err)
})
request.end()
})
}
export const registerSkillsHandlers = (ipcMain: IpcMain): void => {
// Clear API cache (used by marketplace refresh button)
ipcMain.handle('clawhub:cache-clear', () => {
apiCache.clear()
return { ok: true }
})
// Get global skills list from gateway
ipcMain.handle('skills:list', async () => {
return gw('skills.status', {})
@@ -13,6 +78,137 @@ export const registerSkillsHandlers = (ipcMain: IpcMain): void => {
return gw('skills.update', { name: params.name, enabled: params.enabled })
})
// Search ClawHub marketplace
ipcMain.handle('clawhub:search', async (_event, params: { query: string; limit?: number }) => {
try {
const q = encodeURIComponent(params.query)
const limit = params.limit ?? 20
const cacheKey = `search:${q}:${limit}`
const cached = getCached<{ ok: true; results: unknown[] }>(cacheKey)
if (cached) return cached
const data = await clawHubGet<{ results?: unknown[] }>(`/api/v1/search?q=${q}&limit=${limit}`)
const result = { ok: true as const, results: Array.isArray(data.results) ? data.results : [] }
setCache(cacheKey, result, CACHE_TTL_SEARCH)
return result
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Search failed' }
}
})
// Browse / explore ClawHub skills
// NOTE: /api/v1/skills browse endpoint returns empty results, so we use
// the search endpoint with a broad query as a workaround.
ipcMain.handle('clawhub:explore', async (_event, params: { limit?: number; cursor?: string }) => {
try {
const limit = params.limit ?? 30
const cacheKey = `explore:${limit}:${params.cursor ?? ''}`
const cached = getCached<{ ok: true; items: unknown[]; nextCursor: string | null }>(cacheKey)
if (cached) {
console.log(`[ClawHub] explore cache hit (${cached.items.length} items)`)
return cached
}
// Use search with broad queries to populate the "explore" view
const queries = ['skill', 'agent', 'code', 'web', 'data']
const allItems = new Map<string, unknown>()
for (const q of queries) {
if (allItems.size >= limit) break
try {
const data = await clawHubGet<{ results?: unknown[] }>(`/api/v1/search?q=${q}&limit=${limit}`)
if (Array.isArray(data.results)) {
for (const item of data.results) {
const slug = (item as Record<string, unknown>).slug as string
if (slug && !allItems.has(slug)) allItems.set(slug, item)
}
}
} catch { /* skip failed queries */ }
}
console.log(`[ClawHub] explore fetched ${allItems.size} unique items via search`)
const result = {
ok: true as const,
items: [...allItems.values()].slice(0, limit),
nextCursor: null,
}
setCache(cacheKey, result, CACHE_TTL_EXPLORE)
return result
} catch (err) {
console.error(`[ClawHub] explore error:`, err instanceof Error ? err.message : err)
return { ok: false, error: err instanceof Error ? err.message : 'Failed to fetch skills' }
}
})
// Install a skill from ClawHub via `npx clawhub install <slug>`
// Includes automatic retry on rate-limit (429) errors
ipcMain.handle('clawhub:install', async (_event, params: { name: string; installId: string; timeoutMs?: number }) => {
const slug = params.name
const timeoutMs = params.timeoutMs ?? 120_000
const maxRetries = 3
const retryDelayMs = 5_000 // wait 5s between retries
// Resolve the openclaw state dir (~/.openclaw) as CLAWHUB_WORKDIR
const stateDir = process.env.OPENCLAW_STATE_DIR?.trim()
? path.resolve(process.env.OPENCLAW_STATE_DIR.trim().replace(/^~(?=$|[\\/])/, os.homedir()))
: path.join(os.homedir(), '.openclaw')
// Use bundled npm to run `npx --yes clawhub install <slug>`
const npmBin = getBundledNpmBin()
const npxBin = npmBin.replace(/npm(\.cmd)?$/, 'npx$1')
const runInstall = (): Promise<{ ok: boolean; error?: string }> =>
new Promise((resolve) => {
const child = spawn(npxBin, ['--yes', 'clawhub', 'install', slug], {
cwd: stateDir,
env: {
...process.env,
CLAWHUB_WORKDIR: stateDir,
CI: 'true',
FORCE_COLOR: '0',
},
windowsHide: true,
shell: process.platform === 'win32',
})
let stdout = ''
let stderr = ''
child.stdout?.on('data', (d) => { stdout += d.toString() })
child.stderr?.on('data', (d) => { stderr += d.toString() })
const timer = setTimeout(() => {
child.kill()
resolve({ ok: false, error: `Install timed out after ${timeoutMs / 1000}s` })
}, timeoutMs)
child.on('error', (err) => {
clearTimeout(timer)
resolve({ ok: false, error: err.message })
})
child.on('close', (code) => {
clearTimeout(timer)
if (code === 0) {
resolve({ ok: true })
} else {
const errMsg = stderr.trim() || stdout.trim() || `Exit code ${code}`
resolve({ ok: false, error: errMsg })
}
})
})
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const result = await runInstall()
if (result.ok) return result
// Retry on rate-limit errors
const isRateLimit = result.error?.toLowerCase().includes('rate limit')
if (!isRateLimit || attempt >= maxRetries) return result
// Wait before retrying
await new Promise((r) => setTimeout(r, retryDelayMs))
}
return { ok: false, error: 'Install failed after retries' }
})
// Get per-agent skill allowlist from openclaw.json
// Returns: { ok: true, skills: string[] | null }
// null = all skills enabled (no custom allowlist)
+16
View File
@@ -118,6 +118,12 @@ const ipcApi = {
ipcRenderer.invoke('agents:memory:list', params),
agentsMemoryGet: (params: { agentId: string; name: string }) =>
ipcRenderer.invoke('agents:memory:get', params),
agentsWorkspaceTree: (params: { agentId: string }) =>
ipcRenderer.invoke('agents:workspace:tree', params),
agentsWorkspaceRead: (params: { agentId: string; filePath: string }) =>
ipcRenderer.invoke('agents:workspace:read', params),
agentsWorkspaceOpen: (params: { agentId: string; filePath: string }) =>
ipcRenderer.invoke('agents:workspace:open', params),
agentIdentityGet: (params: { agentId?: string; sessionKey?: string }) =>
ipcRenderer.invoke('agent:identity:get', params),
toolsCatalog: (params: { agentId?: string; sessionKey?: string }) =>
@@ -190,6 +196,16 @@ const ipcApi = {
agentSkillsSet: (agentId: string, skills: string[] | null) =>
ipcRenderer.invoke('openclaw:agent-skills:set', { agentId, skills }),
// ── ClawHub marketplace ─────────────────────────────────────────────────
clawHubSearch: (query: string, limit?: number) =>
ipcRenderer.invoke('clawhub:search', { query, limit }),
clawHubExplore: (limit?: number, cursor?: string) =>
ipcRenderer.invoke('clawhub:explore', { limit, cursor }),
clawHubInstall: (name: string, installId: string, timeoutMs?: number) =>
ipcRenderer.invoke('clawhub:install', { name, installId, timeoutMs }),
clawHubCacheClear: () =>
ipcRenderer.invoke('clawhub:cache-clear'),
// ── Extract progress (main → renderer) ────────────────────────────────────
onExtractProgress: (callback: ExtractProgressCallback) => {
extractProgressCallbacks.add(callback)
@@ -1,6 +1,6 @@
import { Pin, Plus, Search, Trash2 } from "lucide-react"
import { Loader2, Pin, Plus, RefreshCw, Search, Trash2 } from "lucide-react"
import { useState } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
@@ -27,18 +27,29 @@ import {
import { GroupAvatar } from "./group-avatar"
import { NewConversationDialog } from "./new-conversation-dialog"
import { CreateGroupDialog } from "./create-group-dialog"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import type { Conversation } from "@/types"
interface ConversationListProps {}
export function ConversationList(_props: ConversationListProps) {
const { state, dispatch } = useApp()
const { state, dispatch, refreshFleet } = useApp()
const { t } = useI18n()
const [search, setSearch] = useState("")
const [newConvOpen, setNewConvOpen] = useState(false)
const [groupDialogOpen, setGroupDialogOpen] = useState(false)
const [refreshing, setRefreshing] = useState(false)
useAvatarVersion() // re-render when avatar changes
const handleRefresh = async () => {
setRefreshing(true)
try {
await refreshFleet()
} finally {
setRefreshing(false)
}
}
const filtered = state.conversations.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase())
)
@@ -73,7 +84,23 @@ export function ConversationList(_props: ConversationListProps) {
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}>
<div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:bg-accent"
onClick={handleRefresh}
disabled={refreshing}
/>
}
>
<RefreshCw className={cn("h-4 w-4", refreshing && "animate-spin")} />
</TooltipTrigger>
<TooltipContent>{t("common.refresh")}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger
render={
@@ -121,6 +148,14 @@ export function ConversationList(_props: ConversationListProps) {
) : (
<>
{/* 已连接但 agent 列表为空:加载中 */}
{filtered.length === 0 && state.agents.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<p className="text-xs">{t("conversationList.loadingAgents")}</p>
</div>
)}
{/* 置顶区域 - 网格头像样式 */}
{pinnedConvs.length > 0 && (
<div className="px-2 pt-1 pb-2">
@@ -2,10 +2,13 @@ import { useCallback, useEffect, useState } from "react"
import {
ChevronDown,
ChevronRight,
ExternalLink,
FileText,
FolderClosed,
FolderOpen,
Loader2,
PanelRightClose,
RefreshCw,
Save,
} from "lucide-react"
import { toast } from "sonner"
@@ -15,29 +18,34 @@ import { ScrollArea } from "@/components/ui/scroll-area"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
import { useI18n } from "@/i18n"
import { AGENT_FILE_NAMES } from "@/lib/agents/agentFiles"
// ── Types ────────────────────────────────────────────────────────────────────
type BootstrapFile = {
type TreeNode = {
name: string
type: "file" | "dir"
path: string
missing: boolean
size?: number
updatedAtMs?: number
children?: TreeNode[]
}
type MemoryFile = {
type SelectedFile = {
path: string
name: string
size: number
updatedAtMs: number
isBootstrap: boolean
}
type SelectedFile =
| { source: "bootstrap"; name: string }
| { source: "memory"; name: string }
// ── Helpers ──────────────────────────────────────────────────────────────────
const BOOTSTRAP_SET = new Set<string>(AGENT_FILE_NAMES)
function isBootstrapFile(node: TreeNode): boolean {
// Root-level files matching bootstrap names
return node.type === "file" && !node.path.includes("/") && BOOTSTRAP_SET.has(node.name)
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
@@ -57,13 +65,12 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
// data
const [workspace, setWorkspace] = useState("")
const [bootstrapFiles, setBootstrapFiles] = useState<BootstrapFile[]>([])
const [memoryFiles, setMemoryFiles] = useState<MemoryFile[]>([])
const [tree, setTree] = useState<TreeNode[]>([])
const [loadingList, setLoadingList] = useState(false)
// tree state
const [memoryExpanded, setMemoryExpanded] = useState(false)
// selection
const [selected, setSelected] = useState<SelectedFile | null>(null)
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set())
// preview state
const [content, setContent] = useState("")
@@ -71,27 +78,18 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
const [loadingFile, setLoadingFile] = useState(false)
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const [fileError, setFileError] = useState<string | null>(null)
const isMemoryFile = selected?.source === "memory"
const selectedName = selected?.name ?? ""
// ── Load file list ──────────────────────────────────────────────────────
// ── Load file tree ──────────────────────────────────────────────────────
const loadFiles = useCallback(async () => {
if (!agentId) return
setLoadingList(true)
try {
const [filesRes, memRes] = await Promise.all([
window.ipc.agentsFilesList({ agentId }),
window.ipc.agentsMemoryList({ agentId }),
])
if (filesRes.ok) {
const r = filesRes.result as { workspace?: string; files?: BootstrapFile[] }
setWorkspace(r.workspace ?? "")
setBootstrapFiles(r.files ?? [])
}
if (memRes.ok) {
setMemoryFiles((memRes as { ok: true; files: MemoryFile[] }).files ?? [])
const res = await window.ipc.agentsWorkspaceTree({ agentId })
if (res.ok) {
setWorkspace(res.workspace ?? "")
setTree(res.tree ?? [])
}
} finally {
setLoadingList(false)
@@ -104,6 +102,7 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
setContent("")
setEditContent("")
setDirty(false)
setFileError(null)
loadFiles()
}
}, [open, agentId, loadFiles])
@@ -114,8 +113,10 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
async (file: SelectedFile) => {
setLoadingFile(true)
setDirty(false)
setFileError(null)
try {
if (file.source === "bootstrap") {
if (file.isBootstrap) {
// Bootstrap files: use gateway API (supports editing)
const res = await window.ipc.agentsFilesGet({ agentId, name: file.name })
if (res.ok) {
const c = (res.result as { file?: { content?: string } })?.file?.content ?? ""
@@ -126,21 +127,33 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
setEditContent("")
}
} else {
const res = await window.ipc.agentsMemoryGet({ agentId, name: file.name })
// Other files: use filesystem read (read-only)
const res = await window.ipc.agentsWorkspaceRead({ agentId, filePath: file.path })
if (res.ok) {
const c = (res as { ok: true; content?: string }).content ?? ""
if (res.binary) {
setContent("")
setEditContent("")
setFileError(t("workspace.binaryFile"))
} else if (res.tooLarge) {
setContent("")
setEditContent("")
setFileError(t("workspace.fileTooLarge"))
} else {
const c = res.content ?? ""
setContent(c)
setEditContent(c)
}
} else {
setContent("")
setEditContent("")
setFileError(res.error ?? "")
}
}
} finally {
setLoadingFile(false)
}
},
[agentId]
[agentId, t]
)
const handleSelectFile = (file: SelectedFile) => {
@@ -148,10 +161,22 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
loadFileContent(file)
}
const toggleDir = (dirPath: string) => {
setExpandedDirs((prev) => {
const next = new Set(prev)
if (next.has(dirPath)) {
next.delete(dirPath)
} else {
next.add(dirPath)
}
return next
})
}
// ── Save ────────────────────────────────────────────────────────────────
const handleSave = async () => {
if (!selected || selected.source !== "bootstrap") return
if (!selected || !selected.isBootstrap) return
setSaving(true)
try {
const res = await window.ipc.agentsFilesSet({
@@ -172,13 +197,90 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
}
}
// ── Render tree recursively ─────────────────────────────────────────────
const renderTree = (nodes: TreeNode[], depth: number) =>
nodes.map((node) => {
if (node.type === "dir") {
const isExpanded = expandedDirs.has(node.path)
return (
<div key={node.path}>
<button
className={cn(
"w-full flex items-center gap-1.5 py-1.5 text-left hover:bg-accent/50 transition-colors",
)}
style={{ paddingLeft: `${depth * 16 + 12}px`, paddingRight: "12px" }}
onClick={() => toggleDir(node.path)}
>
{isExpanded ? (
<ChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground shrink-0" />
)}
{isExpanded ? (
<FolderOpen className="h-3.5 w-3.5 text-amber-500 shrink-0" />
) : (
<FolderClosed className="h-3.5 w-3.5 text-amber-500 shrink-0" />
)}
<span className="text-xs font-medium truncate">{node.name}/</span>
{node.children && node.children.length > 0 && (
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
{node.children.length}
</span>
)}
</button>
{isExpanded && node.children && renderTree(node.children, depth + 1)}
</div>
)
}
const bootstrap = isBootstrapFile(node)
const isSelected =
selected?.path === node.path
return (
<div
key={node.path}
className={cn(
"group flex items-center gap-1.5 py-1.5 transition-colors",
isSelected ? "bg-accent" : "hover:bg-accent/50"
)}
style={{ paddingLeft: `${depth * 16 + 12}px`, paddingRight: "12px" }}
>
<button
className="flex items-center gap-1.5 flex-1 min-w-0 text-left"
onClick={() =>
handleSelectFile({ path: node.path, name: node.name, isBootstrap: bootstrap })
}
>
<FileText className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-xs font-mono truncate flex-1">{node.name}</span>
</button>
{node.size != null && (
<span className="text-[10px] text-muted-foreground shrink-0">
{formatSize(node.size)}
</span>
)}
<button
className="h-5 w-5 shrink-0 flex items-center justify-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-accent transition-all"
title={t("workspace.openExternal")}
onClick={(e) => {
e.stopPropagation()
window.ipc.agentsWorkspaceOpen({ agentId, filePath: node.path })
}}
>
<ExternalLink className="h-3 w-3" />
</button>
</div>
)
})
return (
<div
className="border-l flex flex-col bg-background shrink-0 overflow-hidden transition-[width] duration-300 ease-in-out"
style={{ width: open ? 360 : 0, borderLeftWidth: open ? 1 : 0 }}
>
<div className="w-[360px] flex flex-col h-full shrink-0">
{/* Header — Windows 下右侧留出窗口控件的空间,避免关闭按钮和系统按钮重叠 */}
{/* Header */}
<div
className="h-12 flex items-center justify-between px-3 border-b shrink-0"
style={{
@@ -189,6 +291,17 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
<FolderOpen className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate">{t("workspace.title")}</span>
</div>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground"
onClick={() => loadFiles()}
disabled={loadingList}
title={t("workspace.refresh")}
>
<RefreshCw className={cn("h-3.5 w-3.5", loadingList && "animate-spin")} />
</Button>
<Button
variant="ghost"
size="icon"
@@ -198,6 +311,7 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
<PanelRightClose className="h-4 w-4" />
</Button>
</div>
</div>
{/* Workspace path */}
{workspace && (
@@ -215,61 +329,15 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
</div>
) : (
<div className="flex-1 flex flex-col min-h-0">
{/* File list — scrollable, max ~40% height */}
{/* File tree — scrollable, max ~40% height */}
<ScrollArea className="shrink-0 max-h-[40%] border-b">
<div className="py-1">
{/* Bootstrap files */}
{bootstrapFiles.map((file) => (
<TreeFileItem
key={file.name}
name={file.name}
size={file.size}
missing={file.missing}
isSelected={
selected?.source === "bootstrap" && selected.name === file.name
}
onClick={() =>
handleSelectFile({ source: "bootstrap", name: file.name })
}
/>
))}
{/* Memory folder */}
{memoryFiles.length > 0 && (
<>
<button
className={cn(
"w-full flex items-center gap-1.5 px-3 py-1.5 text-left hover:bg-accent/50 transition-colors"
)}
onClick={() => setMemoryExpanded((p) => !p)}
>
{memoryExpanded ? (
<ChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
{tree.length > 0 ? (
renderTree(tree, 0)
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground shrink-0" />
)}
<FolderOpen className="h-3.5 w-3.5 text-amber-500 shrink-0" />
<span className="text-xs font-medium truncate">memory/</span>
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
{memoryFiles.length}
</span>
</button>
{memoryExpanded &&
memoryFiles.map((file) => (
<TreeFileItem
key={file.name}
name={file.name}
size={file.size}
indent
isSelected={
selected?.source === "memory" && selected.name === file.name
}
onClick={() =>
handleSelectFile({ source: "memory", name: file.name })
}
/>
))}
</>
<p className="text-xs text-muted-foreground text-center py-4">
{t("workspace.emptyFile")}
</p>
)}
</div>
</ScrollArea>
@@ -282,10 +350,10 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
<div className="flex items-center gap-1.5 min-w-0">
<FileText className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-xs font-mono font-medium truncate">
{isMemoryFile ? `memory/${selectedName}` : selectedName}
{selected.path}
</span>
</div>
{!isMemoryFile && (
{selected.isBootstrap ? (
<Button
size="sm"
className="h-6 gap-1 text-xs px-2"
@@ -299,8 +367,7 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
)}
{t("workspace.save")}
</Button>
)}
{isMemoryFile && (
) : (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4">
{t("workspace.readonly")}
</Badge>
@@ -312,6 +379,10 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
<div className="flex-1 flex items-center justify-center text-muted-foreground gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : fileError ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<p className="text-xs">{fileError}</p>
</div>
) : (
<Textarea
className="flex-1 resize-none font-mono text-xs min-h-0 border-0 rounded-none focus-visible:ring-0"
@@ -320,11 +391,11 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
setEditContent(e.target.value)
setDirty(e.target.value !== content)
}}
readOnly={isMemoryFile}
readOnly={!selected.isBootstrap}
placeholder={
isMemoryFile
? t("workspace.emptyMemory")
: t("workspace.emptyFile")
selected.isBootstrap
? t("workspace.emptyFile")
: t("workspace.readonly")
}
/>
)}
@@ -340,47 +411,3 @@ export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelPr
</div>
)
}
// ── TreeFileItem ─────────────────────────────────────────────────────────────
function TreeFileItem({
name,
size,
missing,
indent,
isSelected,
onClick,
}: {
name: string
size?: number
missing?: boolean
indent?: boolean
isSelected: boolean
onClick: () => void
}) {
return (
<button
className={cn(
"w-full flex items-center gap-1.5 py-1.5 text-left transition-colors",
indent ? "px-8" : "px-3",
isSelected ? "bg-accent" : "hover:bg-accent/50"
)}
onClick={onClick}
>
<FileText className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-xs font-mono truncate flex-1">{name}</span>
{missing ? (
<Badge
variant="outline"
className="text-[9px] px-1 py-0 h-3.5 text-yellow-600 border-yellow-400 shrink-0"
>
!
</Badge>
) : size != null ? (
<span className="text-[10px] text-muted-foreground shrink-0">
{formatSize(size)}
</span>
) : null}
</button>
)
}
@@ -281,7 +281,12 @@ export function NavRail() {
/>
}
>
<div className="relative">
<Settings className="h-5 w-5" />
{!state.modelsConfigured && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-destructive" />
)}
</div>
</TooltipTrigger>
<TooltipContent side="right">{t("nav.settings")}</TooltipContent>
</Tooltip>
@@ -5,7 +5,7 @@ const ANSI_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g
function stripAnsi(str: string): string {
return str.replace(ANSI_RE, "")
}
import { Loader2, Wifi, Minus, X, FolderOpen, HardDrive } from "lucide-react"
import { Loader2, Wifi, Minus, X, FolderOpen, HardDrive, AlertTriangle, Settings as SettingsIcon } from "lucide-react"
import { Camera } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
@@ -17,6 +17,7 @@ import { markOnboardingDone, setUserAvatar, setUserName } from "@/lib/avatar"
import { useI18n } from "@/i18n"
import { useApp } from "@/store/app-context"
import logoSvg from "@/assets/logo.svg"
import { SettingsDialog } from "@/components/settings/settings-dialog"
// ── Preset avatars ─────────────────────────────────────────────────────────────
@@ -511,10 +512,110 @@ function ProfileSetupStep({ onDone }: { onDone: () => void }) {
)
}
// ── Step 3: Model configuration check ────────────────────────────────────────
function ModelCheckStep({ onDone, onOpenSettings }: { onDone: () => void; onOpenSettings: () => void }) {
const { t } = useI18n()
const [checking, setChecking] = useState(true)
const [configured, setConfigured] = useState(false)
const checkModels = useCallback(async () => {
setChecking(true)
try {
const res = await window.ipc.openclawModelsGet()
if (res?.ok) {
const result = res as { providers: Record<string, unknown>; defaults: { primary: string } }
const hasProviders = result.providers && Object.keys(result.providers).length > 0
const hasPrimary = !!result.defaults?.primary
if (hasProviders && hasPrimary) {
setConfigured(true)
return true
}
}
} catch { /* ignore */ }
setConfigured(false)
setChecking(false)
return false
}, [])
useEffect(() => {
checkModels().then((ok) => {
if (ok) onDone()
})
}, [checkModels, onDone])
// Re-check when settings dialog closes
const recheck = useCallback(() => {
checkModels().then((ok) => {
if (ok) onDone()
})
}, [checkModels, onDone])
// Expose recheck for parent to call after settings close
useEffect(() => {
(window as unknown as Record<string, unknown>).__modelCheckRecheck = recheck
return () => { delete (window as unknown as Record<string, unknown>).__modelCheckRecheck }
}, [recheck])
if (checking && !configured) {
return (
<div className="flex items-center justify-center">
<Loader2 className="h-8 w-8 text-primary animate-spin" />
</div>
)
}
if (configured) return null
return (
<div className="flex flex-col items-center justify-center gap-6 text-center">
<img src={logoSvg} alt={APP_NAME} className="h-16 w-auto" />
<div className="w-[400px] rounded-xl border bg-card p-6 text-left space-y-4 shadow-md">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-amber-100 text-amber-600">
<AlertTriangle className="h-5 w-5" />
</div>
<div className="space-y-1">
<h2 className="text-base font-semibold">{t("onboarding.modelCheckTitle")}</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
{t("onboarding.modelCheckDesc")}
</p>
</div>
</div>
<div className="flex gap-2 pt-1">
<Button className="flex-1" onClick={onOpenSettings}>
<SettingsIcon className="h-4 w-4 mr-1.5" />
{t("onboarding.modelCheckConfigure")}
</Button>
<Button variant="outline" className="flex-1" onClick={onDone}>
{t("onboarding.modelCheckSkip")}
</Button>
</div>
<p className="text-xs text-muted-foreground text-center">
{t("onboarding.modelCheckSkipHint")}
</p>
</div>
</div>
)
}
// ── GatewayLoadingScreen(老用户启动时全屏 loading)──────────────────────────
// 与 OnboardingFlow 共用 GatewayLoadingStep,但跳过 profile setup 直接进主界面。
export function GatewayLoadingScreen() {
// 老用户路径:如果 settings.json 的 dataLocationSelected 被意外清除(卸载重装等),
// 主进程会一直等渲染层信号。这里兜底:标记默认 + 触发初始化。
useEffect(() => {
window.ipc.dataLocationNeedSelect().then((needSelect) => {
if (needSelect) {
window.ipc.dataLocationUseDefault().then(() => {
window.ipc.dataLocationStartInit()
})
}
}).catch(() => { /* ignore */ })
}, [])
return (
<div className="h-screen relative flex items-center justify-center bg-background overflow-hidden"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
@@ -552,8 +653,9 @@ interface OnboardingFlowProps {
export function OnboardingFlow({ onDone }: OnboardingFlowProps) {
const { state } = useApp()
const [step, setStep] = useState<"data-location" | "gateway" | "profile">("gateway")
const [step, setStep] = useState<"data-location" | "gateway" | "profile" | "model-check">("gateway")
const [checkingDataLocation, setCheckingDataLocation] = useState(true)
const [settingsOpen, setSettingsOpen] = useState(false)
// 首次渲染时检查是否需要选择数据目录
useEffect(() => {
@@ -623,9 +725,31 @@ export function OnboardingFlow({ onDone }: OnboardingFlowProps) {
"transition-all duration-300 px-6",
step === "profile" ? "opacity-100" : "opacity-0 pointer-events-none absolute"
)}>
{step === "profile" && <ProfileSetupStep onDone={onDone} />}
{step === "profile" && <ProfileSetupStep onDone={() => setStep("model-check")} />}
</div>
<div className={cn(
"transition-all duration-300",
step === "model-check" ? "opacity-100" : "opacity-0 pointer-events-none absolute"
)}>
{step === "model-check" && (
<ModelCheckStep
onDone={onDone}
onOpenSettings={() => setSettingsOpen(true)}
/>
)}
</div>
</div>
<SettingsDialog
open={settingsOpen}
onOpenChange={(open) => {
setSettingsOpen(open)
if (!open) {
// Re-check models after settings dialog closes
const recheck = (window as unknown as Record<string, () => void>).__modelCheckRecheck
if (recheck) recheck()
}
}}
/>
</div>
)
}
@@ -22,9 +22,11 @@ import type {
import { API_TYPE_OPTIONS } from "./model-config-types"
import { KNOWN_PROVIDERS } from "./known-providers"
import { ProviderCard } from "./provider-card"
import { useApp } from "@/store/app-context"
export function ModelConfigPanel() {
const { t } = useI18n()
const { checkModelsConfigured } = useApp()
const [providers, setProviders] = useState<Record<string, ModelProvider>>({})
const [defaults, setDefaults] = useState<ModelDefaults>({ primary: "", fallbacks: [] })
const [loading, setLoading] = useState(true)
@@ -91,6 +93,7 @@ export function ModelConfigPanel() {
})
if (!res || !res.ok) throw new Error((res as { error?: string })?.error ?? 'Save failed')
toast.success(t("modelConfig.saved"))
checkModelsConfigured()
await loadData()
} catch (err) {
toast.error(t("modelConfig.saveFailed") + String(err))
@@ -15,6 +15,7 @@ import {
type LocalePreference,
} from "@/i18n"
import { cn } from "@/lib/utils"
import { useApp } from "@/store/app-context"
import { ModelConfigPanel } from "./model-config"
import { GatewayConfigPanel } from "./gateway-config-panel"
import { OpenclawUpdatePanel } from "./openclaw-update-panel"
@@ -28,9 +29,10 @@ type SettingsSection = "models" | "gateway" | "storage" | "language" | "about"
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
const { t } = useI18n()
const { state } = useApp()
const [activeSection, setActiveSection] = useState<SettingsSection>("models")
const navItems: { id: SettingsSection; label: string; icon: typeof Brain }[] = [
{ id: "models", label: t("settings.sections.models"), icon: Brain },
const navItems: { id: SettingsSection; label: string; icon: typeof Brain; showDot?: boolean }[] = [
{ id: "models", label: t("settings.sections.models"), icon: Brain, showDot: !state.modelsConfigured },
{ id: "gateway", label: t("settings.sections.gateway"), icon: Server },
{ id: "storage", label: t("settings.sections.storage"), icon: HardDrive },
{ id: "language", label: t("settings.sections.language"), icon: Languages },
@@ -67,7 +69,12 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<div className="relative">
<item.icon className="h-4 w-4" />
{item.showDot && (
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-destructive" />
)}
</div>
{item.label}
</button>
))}
@@ -1,19 +1,60 @@
import { useEffect, useState } from "react"
import { Puzzle, Loader2, RefreshCw } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { useCallback, useEffect, useRef, useState } from "react"
import {
Download,
ExternalLink,
Loader2,
Puzzle,
RefreshCw,
Search,
Star,
Store,
} from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
import { useI18n } from "@/i18n"
import { toast } from "sonner"
// ── Types ────────────────────────────────────────────────────────────────
interface Skill {
name: string
description?: string
version?: string
enabled: boolean
source?: string
install?: { id?: string; kind: string; label?: string }[]
}
interface MarketplaceSkill {
slug: string
displayName?: string
name?: string
description?: string
version?: string
score?: number
author?: string
downloads?: number
stars?: number
versions?: number
changelog?: string
license?: string
createdAt?: number
}
type Tab = "installed" | "marketplace"
// ── Helpers ──────────────────────────────────────────────────────────────
export function parseSkillsResult(raw: unknown): Skill[] {
const list: unknown[] = Array.isArray(raw)
? raw
@@ -23,20 +64,143 @@ export function parseSkillsResult(raw: unknown): Skill[] {
return list
.map((s) => {
if (typeof s === 'string') return { name: s, enabled: true }
if (typeof s === "string") return { name: s, enabled: true }
const sk = s as Record<string, unknown>
return {
name: typeof sk.name === 'string' ? sk.name : '',
description: typeof sk.description === 'string' ? sk.description : undefined,
version: typeof sk.version === 'string' ? sk.version : undefined,
enabled: typeof sk.enabled === 'boolean' ? sk.enabled : true,
source: typeof sk.source === 'string' ? sk.source : undefined,
name: typeof sk.name === "string" ? sk.name : "",
description: typeof sk.description === "string" ? sk.description : undefined,
version: typeof sk.version === "string" ? sk.version : undefined,
enabled: typeof sk.enabled === "boolean" ? sk.enabled : true,
source: typeof sk.source === "string" ? sk.source : undefined,
install: Array.isArray(sk.install) ? (sk.install as Skill["install"]) : undefined,
}
})
.filter((s) => s.name)
}
function parseMarketplaceItems(raw: unknown[]): MarketplaceSkill[] {
return raw
.map((item) => {
const r = item as Record<string, unknown>
const stats = r.stats as Record<string, unknown> | undefined
const latestVersion = r.latestVersion as Record<string, unknown> | undefined
return {
slug: typeof r.slug === "string" ? r.slug : "",
displayName: typeof r.displayName === "string" ? r.displayName : undefined,
name: typeof r.name === "string" ? r.name : undefined,
description:
typeof r.description === "string"
? r.description
: typeof r.summary === "string"
? r.summary
: undefined,
version:
typeof r.version === "string"
? r.version
: typeof latestVersion?.version === "string"
? latestVersion.version
: undefined,
score: typeof r.score === "number" ? r.score : undefined,
author: typeof r.author === "string" ? r.author : undefined,
downloads:
typeof r.downloads === "number"
? r.downloads
: typeof stats?.downloads === "number"
? stats.downloads
: undefined,
stars: typeof stats?.stars === "number" ? stats.stars : undefined,
versions: typeof stats?.versions === "number" ? stats.versions : undefined,
changelog: typeof latestVersion?.changelog === "string" ? latestVersion.changelog : undefined,
license: typeof latestVersion?.license === "string" ? latestVersion.license : undefined,
createdAt: typeof r.createdAt === "number" ? r.createdAt : undefined,
}
})
.filter((s) => s.slug)
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})
}
// ── Main Component ───────────────────────────────────────────────────────
export function SkillsView() {
const { t } = useI18n()
const [tab, setTab] = useState<Tab>("installed")
return (
<div className="flex-1 flex flex-col overflow-hidden bg-muted/20">
{/* Page Header */}
<div
className="shrink-0 flex items-center px-8 py-5 border-b bg-background"
style={{
WebkitAppRegion: "drag",
...(window.ipc.platform !== "darwin" ? { paddingRight: "154px" } : {}),
} as React.CSSProperties}
>
<div
className="flex items-center justify-center h-9 w-9 rounded-lg bg-primary/10 shrink-0"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<Puzzle className="h-5 w-5 text-primary" />
</div>
<div className="ml-3" style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}>
<h1 className="text-lg font-semibold">{t("skills.title")}</h1>
<p className="text-xs text-muted-foreground">{t("skills.subtitle")}</p>
</div>
</div>
{/* Tabs */}
<div className="shrink-0 px-8 pt-4 pb-0">
<div className="flex gap-1 bg-muted/50 rounded-lg p-1 w-fit">
<button
onClick={() => setTab("installed")}
className={cn(
"px-4 py-1.5 text-sm rounded-md transition-colors",
tab === "installed"
? "bg-background text-foreground shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
)}
>
{t("skills.tabInstalled")}
</button>
<button
onClick={() => setTab("marketplace")}
className={cn(
"px-4 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1.5",
tab === "marketplace"
? "bg-background text-foreground shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
)}
>
<Store className="h-3.5 w-3.5" />
{t("skills.tabMarketplace")}
</button>
</div>
</div>
{/* Tab Content — both always mounted, hidden via CSS to avoid refetch */}
<div
className="flex-1 overflow-y-auto px-8 py-6"
style={{ display: tab === "installed" ? undefined : "none" }}
>
<div className="max-w-2xl mx-auto">
<InstalledTab />
</div>
</div>
<MarketplaceTab hidden={tab !== "marketplace"} />
</div>
)
}
// ── Installed Skills Tab ─────────────────────────────────────────────────
function InstalledTab() {
const { t } = useI18n()
const [skills, setSkills] = useState<Skill[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -59,7 +223,9 @@ export function SkillsView() {
}
}
useEffect(() => { loadSkills() }, [])
useEffect(() => {
loadSkills()
}, [])
const handleToggle = async (name: string, enabled: boolean) => {
setToggling(name)
@@ -69,56 +235,37 @@ export function SkillsView() {
const result = await window.ipc.skillsToggle(name, enabled)
if (!(result as { ok: boolean }).ok) {
setSkills(prev)
toast.error((result as { ok: false; error: string }).error ?? '操作失败')
toast.error((result as { ok: false; error: string }).error ?? t("skills.installFailed"))
}
} catch {
setSkills(prev)
toast.error('操作失败')
toast.error(t("skills.installFailed"))
} finally {
setToggling(null)
}
}
if (error) {
return (
<div className="flex-1 flex flex-col overflow-hidden bg-muted/20">
{/* Page Header */}
<div
className="shrink-0 flex items-center px-8 py-5 border-b bg-background"
style={{
WebkitAppRegion: "drag",
...(window.ipc.platform !== 'darwin' ? { paddingRight: '154px' } : {}),
} as React.CSSProperties}
>
<div
className="flex items-center justify-center h-9 w-9 rounded-lg bg-primary/10 shrink-0"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<Puzzle className="h-5 w-5 text-primary" />
</div>
<div className="ml-3" style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}>
<h1 className="text-lg font-semibold">Skills</h1>
<p className="text-xs text-muted-foreground">全局技能库管理</p>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-8 py-6">
<div className="max-w-2xl mx-auto">
{error ? (
<Card className="p-6 text-center text-sm text-muted-foreground">
<p className="mb-3">{error}</p>
<Button size="sm" variant="outline" onClick={loadSkills}>重试</Button>
<Button size="sm" variant="outline" onClick={loadSkills}>
{t("skills.retry")}
</Button>
</Card>
) : (
)
}
return (
<Card className="p-5">
<div className="flex items-center gap-2 mb-4">
<Puzzle className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">全局技能库</h2>
<h2 className="text-sm font-semibold">{t("skills.installed")}</h2>
<button
onClick={loadSkills}
disabled={loading}
className="ml-auto text-muted-foreground/50 hover:text-foreground transition-colors disabled:pointer-events-none"
title="刷新"
title={t("skills.refresh")}
>
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
</button>
@@ -127,10 +274,12 @@ export function SkillsView() {
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4">
<Loader2 className="h-4 w-4 animate-spin" />
加载中...
{t("skills.loading")}
</div>
) : skills.length === 0 ? (
<p className="text-xs text-muted-foreground py-4 text-center">暂无可用技能</p>
<p className="text-xs text-muted-foreground py-4 text-center">
{t("skills.noSkills")}
</p>
) : (
<div className="space-y-0">
{skills.map((skill, i) => (
@@ -151,8 +300,6 @@ export function SkillsView() {
<p className="text-xs text-muted-foreground mt-0.5">{skill.description}</p>
)}
</div>
{/* Toggle switch */}
<button
onClick={() => handleToggle(skill.name, !skill.enabled)}
disabled={toggling === skill.name}
@@ -161,7 +308,7 @@ export function SkillsView() {
skill.enabled ? "bg-primary" : "bg-muted-foreground/30",
toggling === skill.name && "opacity-50 cursor-not-allowed"
)}
title={skill.enabled ? "点击禁用" : "点击启用"}
title={skill.enabled ? t("skills.disable") : t("skills.enable")}
>
<span
className={cn(
@@ -176,9 +323,504 @@ export function SkillsView() {
</div>
)}
</Card>
)
}
// ── Marketplace Tab ──────────────────────────────────────────────────────
function MarketplaceTab({ hidden }: { hidden?: boolean }) {
const { t } = useI18n()
const [query, setQuery] = useState("")
const [results, setResults] = useState<MarketplaceSkill[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isSearchMode, setIsSearchMode] = useState(false)
const [installedNames, setInstalledNames] = useState<Set<string>>(new Set())
const [installingSlug, setInstallingSlug] = useState<string | null>(null)
const [nextCursor, setNextCursor] = useState<string | null>(null)
const [detailSkill, setDetailSkill] = useState<MarketplaceSkill | null>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const loadingMoreRef = useRef(false)
// Load installed skill names for "installed" badge
useEffect(() => {
window.ipc.skillsList().then((res) => {
if (res.ok) {
const skills = parseSkillsResult(res.result)
setInstalledNames(new Set(skills.map((s) => s.name)))
}
})
}, [])
// Auto-load popular skills on mount
const loadExplore = useCallback(async () => {
setLoading(true)
setError(null)
setIsSearchMode(false)
setNextCursor(null)
try {
const res = await window.ipc.clawHubExplore(30)
console.log('[Skills] explore result:', JSON.stringify(res).slice(0, 300))
if (res.ok) {
const items = parseMarketplaceItems(res.items ?? [])
console.log('[Skills] parsed items:', items.length)
setResults(items)
setNextCursor(res.nextCursor ?? null)
} else {
setError((res as { ok: false; error: string }).error)
}
} catch (e) {
console.error('[Skills] explore error:', e)
setError(String(e))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadExplore()
}, [loadExplore])
// Load more for infinite scroll (explore mode only)
const loadMore = useCallback(async () => {
if (!nextCursor || isSearchMode || loadingMoreRef.current) return
loadingMoreRef.current = true
setLoadingMore(true)
try {
const res = await window.ipc.clawHubExplore(30, nextCursor)
if (res.ok) {
const newItems = parseMarketplaceItems(res.items ?? [])
setResults((prev) => {
const existingSlugs = new Set(prev.map((s) => s.slug))
const unique = newItems.filter((s) => !existingSlugs.has(s.slug))
return [...prev, ...unique]
})
setNextCursor(res.nextCursor ?? null)
}
} finally {
setLoadingMore(false)
loadingMoreRef.current = false
}
}, [nextCursor, isSearchMode])
// Infinite scroll handler
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el || !nextCursor || isSearchMode || loadingMoreRef.current) return
const { scrollTop, scrollHeight, clientHeight } = el
if (scrollHeight - scrollTop - clientHeight < 200) {
loadMore()
}
}, [loadMore, nextCursor, isSearchMode])
const doSearch = useCallback(
async (q: string) => {
if (!q.trim()) {
loadExplore()
return
}
setLoading(true)
setError(null)
setIsSearchMode(true)
setNextCursor(null)
try {
const res = await window.ipc.clawHubSearch(q.trim(), 30)
if (res.ok) {
setResults(parseMarketplaceItems(res.results ?? []))
} else {
setError((res as { ok: false; error: string }).error)
setResults([])
}
} catch (e) {
setError(String(e))
setResults([])
} finally {
setLoading(false)
}
},
[loadExplore]
)
const handleInputChange = (value: string) => {
setQuery(value)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => doSearch(value), 500)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
if (debounceRef.current) clearTimeout(debounceRef.current)
doSearch(query)
}
}
const handleInstall = async (skill: MarketplaceSkill, e?: React.MouseEvent) => {
e?.stopPropagation()
setInstallingSlug(skill.slug)
try {
const res = await window.ipc.clawHubInstall(skill.slug, "clawhub-download")
if ((res as { ok: boolean }).ok) {
toast.success(t("skills.installSuccess"))
setInstalledNames((prev) => new Set([...prev, skill.slug]))
window.ipc.skillsList().then((listRes) => {
if (listRes.ok) {
const skills = parseSkillsResult(listRes.result)
setInstalledNames(new Set(skills.map((s) => s.name)))
}
})
} else {
toast.error(`${t("skills.installFailed")}: ${(res as { ok: false; error: string }).error}`)
}
} catch (err) {
toast.error(`${t("skills.installFailed")}: ${String(err)}`)
} finally {
setInstallingSlug(null)
}
}
return (
<div
className={cn("flex-1 flex flex-col overflow-hidden", hidden && "hidden")}
>
{/* Search bar — fixed at top */}
<div className="shrink-0 px-8 pt-4 pb-2">
<div className="max-w-2xl mx-auto flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60 pointer-events-none" />
<Input
placeholder={t("skills.searchPlaceholder")}
className="pl-9 h-10"
value={query}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
/>
{loading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
<button
onClick={() => { window.ipc.clawHubCacheClear(); loadExplore() }}
disabled={loading}
className="shrink-0 h-10 w-10 flex items-center justify-center rounded-md border border-input bg-background text-muted-foreground hover:text-foreground hover:bg-accent transition-colors disabled:pointer-events-none disabled:opacity-50"
title={t("skills.refresh")}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</button>
</div>
</div>
{/* Scrollable results area */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto px-8 pb-6"
onScroll={handleScroll}
>
<div className="max-w-2xl mx-auto space-y-3">
{/* Section header */}
{!loading && !error && results.length > 0 && (
<div className="flex items-center justify-between pt-2">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{isSearchMode
? `${t("skills.searchPlaceholder").replace("...", "")} "${query}"`
: t("skills.explore")}
</h3>
<Button
variant="link"
size="sm"
className="text-[10px] h-auto p-0 text-muted-foreground/60"
onClick={() => window.open("https://clawhub.ai", "_blank")}
>
<ExternalLink className="h-3 w-3 mr-0.5" />
{t("skills.openClawHub")}
</Button>
</div>
)}
{/* Error */}
{error && (
<Card className="p-4 text-center text-sm text-muted-foreground">
<p>{t("skills.marketplaceError")}</p>
<p className="text-xs mt-1 opacity-70">{error}</p>
<Button size="sm" variant="outline" className="mt-3" onClick={loadExplore}>
{t("skills.retry")}
</Button>
</Card>
)}
{/* Loading skeleton — initial load */}
{loading && (
<div className="space-y-2 pt-2">
{[...Array(8)].map((_, i) => (
<Card key={i} className="p-4 animate-pulse">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-2">
<div className="h-4 w-40 bg-muted rounded" />
<div className="h-3 w-24 bg-muted/60 rounded" />
<div className="h-3 w-full bg-muted/40 rounded" />
</div>
<div className="h-7 w-16 bg-muted rounded" />
</div>
</Card>
))}
</div>
)}
{/* No results after search */}
{!loading && !error && isSearchMode && results.length === 0 && (
<Card className="p-8 text-center">
<Search className="h-8 w-8 text-muted-foreground/30 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">{t("skills.noResults")}</p>
<p className="text-xs text-muted-foreground/60 mt-1">{t("skills.tryOther")}</p>
</Card>
)}
{/* Results list */}
{!loading &&
results.map((skill) => {
const isInstalled = installedNames.has(skill.slug)
const isInstalling = installingSlug === skill.slug
return (
<Card
key={skill.slug}
className="p-4 cursor-pointer hover:bg-accent/30 transition-colors"
onClick={() => setDetailSkill(skill)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold">
{skill.displayName || skill.name || skill.slug}
</span>
{skill.version && (
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
v{skill.version}
</span>
)}
{skill.score != null && (
<span className="text-[10px] text-muted-foreground/60">
{t("skills.score")}: {(skill.score * 100).toFixed(0)}%
</span>
)}
</div>
<p className="text-xs text-muted-foreground/80 mt-0.5">{skill.slug}</p>
{skill.description && (
<p className="text-xs text-muted-foreground mt-1.5 line-clamp-2">
{skill.description}
</p>
)}
<div className="flex items-center gap-3 mt-2 text-[10px] text-muted-foreground/50">
{skill.downloads != null && (
<span>
{skill.downloads.toLocaleString()} {t("skills.downloads")}
</span>
)}
{skill.stars != null && skill.stars > 0 && (
<span className="flex items-center gap-0.5">
<Star className="h-2.5 w-2.5" />
{skill.stars.toLocaleString()}
</span>
)}
</div>
</div>
<div className="shrink-0">
{isInstalled ? (
<Badge
variant="secondary"
className="text-[10px] bg-green-500/10 text-green-600 border-green-200"
>
{t("skills.installed_badge")}
</Badge>
) : (
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1"
disabled={isInstalling}
onClick={(e) => handleInstall(skill, e)}
>
{isInstalling ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
{t("skills.installing")}
</>
) : (
<>
<Download className="h-3 w-3" />
{t("skills.install")}
</>
)}
</Button>
)}
</div>
</div>
</Card>
)
})}
{/* Load more spinner */}
{loadingMore && (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{/* End of list indicator */}
{!loading && !loadingMore && !isSearchMode && results.length > 0 && !nextCursor && (
<p className="text-center text-[10px] text-muted-foreground/40 py-4">
— {t("skills.noResults").replace(/^.*$/, "·")} —
</p>
)}
</div>
</div>
{/* Skill Detail Dialog */}
<SkillDetailDialog
skill={detailSkill}
onClose={() => setDetailSkill(null)}
isInstalled={detailSkill ? installedNames.has(detailSkill.slug) : false}
isInstalling={detailSkill ? installingSlug === detailSkill.slug : false}
onInstall={() => detailSkill && handleInstall(detailSkill)}
/>
</div>
)
}
// ── Skill Detail Dialog ──────────────────────────────────────────────────
function SkillDetailDialog({
skill,
onClose,
isInstalled,
isInstalling,
onInstall,
}: {
skill: MarketplaceSkill | null
onClose: () => void
isInstalled: boolean
isInstalling: boolean
onInstall: () => void
}) {
const { t } = useI18n()
return (
<Dialog open={!!skill} onOpenChange={(open) => !open && onClose()}>
<DialogContent
showCloseButton={false}
className="sm:max-w-md max-h-[80vh] flex flex-col gap-0 p-0 overflow-hidden"
>
{skill && (
<>
{/* Header */}
<div className="shrink-0 px-5 pt-5 pb-4 space-y-3">
<div className="min-w-0">
<DialogTitle className="text-base font-semibold leading-tight">
{skill.displayName || skill.name || skill.slug}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
{skill.slug}
</DialogDescription>
</div>
{/* Install / Installed button */}
<div>
{!isInstalled ? (
<Button
size="sm"
className="w-full gap-1.5"
disabled={isInstalling}
onClick={onInstall}
>
{isInstalling ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("skills.installing")}
</>
) : (
<>
<Download className="h-3.5 w-3.5" />
{t("skills.install")}
</>
)}
</Button>
) : (
<Badge
variant="secondary"
className="text-xs bg-green-500/10 text-green-600 border-green-200 w-full justify-center py-1.5"
>
{t("skills.installed_badge")}
</Badge>
)}
</div>
</div>
{/* Body — scrollable */}
<div className="flex-1 overflow-y-auto border-t">
<div className="px-5 py-4 space-y-4">
{/* Description */}
{skill.description && (
<p className="text-sm text-foreground/80 leading-relaxed">{skill.description}</p>
)}
{/* Stats row */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
{skill.version && (
<Badge variant="outline" className="text-[10px] font-mono">
v{skill.version}
</Badge>
)}
{skill.license && <span>{skill.license}</span>}
{skill.downloads != null && (
<span className="flex items-center gap-1">
<Download className="h-3 w-3" />
{skill.downloads.toLocaleString()}
</span>
)}
{skill.stars != null && skill.stars > 0 && (
<span className="flex items-center gap-1">
<Star className="h-3 w-3" />
{skill.stars.toLocaleString()}
</span>
)}
{skill.versions != null && (
<span>
{skill.versions} {skill.versions === 1 ? "version" : "versions"}
</span>
)}
{skill.createdAt && <span>{formatDate(skill.createdAt)}</span>}
</div>
{/* Changelog */}
{skill.changelog && (
<div>
<h4 className="text-xs font-medium text-muted-foreground mb-1.5">
{t("skills.changelog")}
</h4>
<div className="text-xs text-foreground/70 whitespace-pre-wrap bg-muted/40 rounded-lg p-3 leading-relaxed">
{skill.changelog}
</div>
</div>
)}
{/* Open in browser link */}
<Button
variant="outline"
size="sm"
className="w-full text-xs gap-1.5"
onClick={() =>
window.open(`https://clawhub.ai/skills/${skill.slug}`, "_blank")
}
>
<ExternalLink className="h-3 w-3" />
{t("skills.viewOnClawHub")}
</Button>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
)
}
@@ -1,5 +1,16 @@
import { useCallback, useEffect, useState } from "react"
import { ArrowLeft, FileText, Loader2, Save } from "lucide-react"
import {
ArrowLeft,
ChevronDown,
ChevronRight,
ExternalLink,
FileText,
FolderClosed,
FolderOpen,
Loader2,
RefreshCw,
Save,
} from "lucide-react"
import { toast } from "sonner"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
@@ -7,19 +18,36 @@ import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
import { getAgentAvatarUrl } from "@/lib/avatar"
import { AGENT_FILE_NAMES } from "@/lib/agents/agentFiles"
import { useI18n } from "@/i18n"
import type { Agent } from "@/types"
type FileEntry = {
// ── Types ────────────────────────────────────────────────────────────────────
type TreeNode = {
name: string
type: "file" | "dir"
path: string
missing: boolean
size?: number
updatedAtMs?: number
content?: string
children?: TreeNode[]
}
type View = { kind: "list" } | { kind: "editor"; file: FileEntry }
type SelectedFile = {
path: string
name: string
isBootstrap: boolean
}
// ── Helpers ──────────────────────────────────────────────────────────────────
const BOOTSTRAP_SET = new Set<string>(AGENT_FILE_NAMES)
function isBootstrapFile(node: TreeNode): boolean {
return node.type === "file" && !node.path.includes("/") && BOOTSTRAP_SET.has(node.name)
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
@@ -27,15 +55,7 @@ function formatSize(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function formatTime(ms: number): string {
const diff = Date.now() - ms
const m = Math.floor(diff / 60000)
if (m < 1) return "刚刚"
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}
// ── AgentFilesSheet ──────────────────────────────────────────────────────────
interface AgentFilesSheetProps {
agent: Agent | null
@@ -44,55 +64,82 @@ interface AgentFilesSheetProps {
}
export function AgentFilesSheet({ agent, open, onOpenChange }: AgentFilesSheetProps) {
const [view, setView] = useState<View>({ kind: "list" })
const [files, setFiles] = useState<FileEntry[]>([])
const { t } = useI18n()
const [view, setView] = useState<"list" | "editor">("list")
const [tree, setTree] = useState<TreeNode[]>([])
const [workspace, setWorkspace] = useState("")
const [loadingList, setLoadingList] = useState(false)
const [loadingFile, setLoadingFile] = useState(false)
const [saving, setSaving] = useState(false)
const [editContent, setEditContent] = useState("")
const [content, setContent] = useState("")
const [selected, setSelected] = useState<SelectedFile | null>(null)
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set())
const [fileError, setFileError] = useState<string | null>(null)
const loadFiles = useCallback(async () => {
if (!agent) return
setLoadingList(true)
try {
const res = await window.ipc.agentsFilesList({ agentId: agent.id })
const res = await window.ipc.agentsWorkspaceTree({ agentId: agent.id })
if (res.ok) {
const r = res.result as { workspace?: string; files?: FileEntry[] }
setWorkspace(r.workspace ?? "")
setFiles(r.files ?? [])
setWorkspace(res.workspace ?? "")
setTree(res.tree ?? [])
} else {
toast.error((res as { error?: string }).error ?? "获取文件列表失败")
toast.error(res.error ?? t("workspace.saveFailed"))
}
} finally {
setLoadingList(false)
}
}, [agent])
}, [agent, t])
useEffect(() => {
if (open && agent) {
setView({ kind: "list" })
setView("list")
setSelected(null)
setFileError(null)
loadFiles()
}
}, [open, agent, loadFiles])
const openFile = async (file: FileEntry) => {
if (file.missing) {
setView({ kind: "editor", file })
setEditContent("")
return
}
const openFile = async (file: SelectedFile) => {
if (!agent) return
setSelected(file)
setView("editor")
setLoadingFile(true)
setFileError(null)
try {
if (file.isBootstrap) {
const res = await window.ipc.agentsFilesGet({ agentId: agent.id, name: file.name })
if (res.ok) {
const f = (res.result as { file?: FileEntry }).file
const populated = { ...file, content: f?.content ?? "" }
setView({ kind: "editor", file: populated })
setEditContent(f?.content ?? "")
const c = (res.result as { file?: { content?: string } })?.file?.content ?? ""
setContent(c)
setEditContent(c)
} else {
toast.error((res as { error?: string }).error ?? "读取文件失败")
setContent("")
setEditContent("")
}
} else {
const res = await window.ipc.agentsWorkspaceRead({ agentId: agent.id, filePath: file.path })
if (res.ok) {
if (res.binary) {
setContent("")
setEditContent("")
setFileError(t("workspace.binaryFile"))
} else if (res.tooLarge) {
setContent("")
setEditContent("")
setFileError(t("workspace.fileTooLarge"))
} else {
const c = res.content ?? ""
setContent(c)
setEditContent(c)
}
} else {
setContent("")
setEditContent("")
setFileError(res.error ?? "")
}
}
} finally {
setLoadingFile(false)
@@ -100,27 +147,104 @@ export function AgentFilesSheet({ agent, open, onOpenChange }: AgentFilesSheetPr
}
const saveFile = async () => {
if (view.kind !== "editor" || !agent) return
if (!selected || !selected.isBootstrap || !agent) return
setSaving(true)
try {
const res = await window.ipc.agentsFilesSet({
agentId: agent.id,
name: view.file.name,
name: selected.name,
content: editContent,
})
if (res.ok) {
toast.success(`${view.file.name} 已保存`)
setView({ kind: "editor", file: { ...view.file, missing: false, content: editContent } })
// refresh list in background
toast.success(`${selected.name} ${t("workspace.saved")}`)
setContent(editContent)
loadFiles()
} else {
toast.error((res as { error?: string }).error ?? "保存失败")
toast.error((res as { error?: string }).error ?? t("workspace.saveFailed"))
}
} finally {
setSaving(false)
}
}
const toggleDir = (dirPath: string) => {
setExpandedDirs((prev) => {
const next = new Set(prev)
if (next.has(dirPath)) {
next.delete(dirPath)
} else {
next.add(dirPath)
}
return next
})
}
const renderTree = (nodes: TreeNode[], depth: number) =>
nodes.map((node) => {
if (node.type === "dir") {
const isExpanded = expandedDirs.has(node.path)
return (
<div key={node.path}>
<button
className="w-full flex items-center gap-2 py-2.5 hover:bg-muted/50 transition-colors text-left"
style={{ paddingLeft: `${depth * 16 + 16}px`, paddingRight: "16px" }}
onClick={() => toggleDir(node.path)}
>
{isExpanded ? (
<ChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground shrink-0" />
)}
{isExpanded ? (
<FolderOpen className="h-4 w-4 text-amber-500 shrink-0" />
) : (
<FolderClosed className="h-4 w-4 text-amber-500 shrink-0" />
)}
<span className="text-sm font-medium truncate flex-1">{node.name}/</span>
{node.children && node.children.length > 0 && (
<span className="text-xs text-muted-foreground shrink-0">
{node.children.length}
</span>
)}
</button>
{isExpanded && node.children && renderTree(node.children, depth + 1)}
</div>
)
}
const bootstrap = isBootstrapFile(node)
return (
<div
key={node.path}
className="group flex items-center gap-2 py-2.5 hover:bg-muted/50 transition-colors"
style={{ paddingLeft: `${depth * 16 + 16}px`, paddingRight: "16px" }}
>
<button
className="flex items-center gap-2 flex-1 min-w-0 text-left"
onClick={() => openFile({ path: node.path, name: node.name, isBootstrap: bootstrap })}
>
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="text-sm font-mono">{node.name}</p>
{node.size != null && (
<p className="text-xs text-muted-foreground">{formatSize(node.size)}</p>
)}
</div>
</button>
<button
className="h-6 w-6 shrink-0 flex items-center justify-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-accent transition-all"
title={t("workspace.openExternal")}
onClick={(e) => {
e.stopPropagation()
if (agent) window.ipc.agentsWorkspaceOpen({ agentId: agent.id, filePath: node.path })
}}
>
<ExternalLink className="h-3.5 w-3.5" />
</button>
</div>
)
})
if (!agent) return null
return (
@@ -128,12 +252,12 @@ export function AgentFilesSheet({ agent, open, onOpenChange }: AgentFilesSheetPr
<SheetContent className="w-[440px] sm:w-[440px] p-0 flex flex-col">
<SheetHeader className="px-4 pt-4 pb-3 border-b shrink-0">
<div className="flex items-center gap-3">
{view.kind === "editor" && (
{view === "editor" && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setView({ kind: "list" })}
onClick={() => { setView("list"); setSelected(null); setFileError(null) }}
>
<ArrowLeft className="h-4 w-4" />
</Button>
@@ -146,8 +270,8 @@ export function AgentFilesSheet({ agent, open, onOpenChange }: AgentFilesSheetPr
</Avatar>
<div className="flex-1 min-w-0">
<SheetTitle className="text-sm leading-tight">{agent.name}</SheetTitle>
{view.kind === "editor" ? (
<p className="text-xs text-muted-foreground font-mono">{view.file.name}</p>
{view === "editor" && selected ? (
<p className="text-xs text-muted-foreground font-mono truncate">{selected.path}</p>
) : (
workspace && (
<p className="text-xs text-muted-foreground truncate" title={workspace}>
@@ -156,55 +280,48 @@ export function AgentFilesSheet({ agent, open, onOpenChange }: AgentFilesSheetPr
)
)}
</div>
{view.kind === "editor" && (
{view === "editor" && selected?.isBootstrap && (
<Button size="sm" className="h-7 gap-1" onClick={saveFile} disabled={saving}>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
保存
{t("workspace.save")}
</Button>
)}
{view === "editor" && selected && !selected.isBootstrap && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4">
{t("workspace.readonly")}
</Badge>
)}
{view === "list" && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground"
onClick={() => loadFiles()}
disabled={loadingList}
title={t("workspace.refresh")}
>
<RefreshCw className={cn("h-3.5 w-3.5", loadingList && "animate-spin")} />
</Button>
)}
</div>
</SheetHeader>
{view.kind === "list" ? (
{view === "list" ? (
<ScrollArea className="flex-1">
{loadingList ? (
<div className="flex items-center justify-center py-12 text-muted-foreground gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">加载中...</span>
<span className="text-sm">{t("workspace.loading")}</span>
</div>
) : tree.length > 0 ? (
<div className="py-1">{renderTree(tree, 0)}</div>
) : (
<div className="divide-y">
{files.map((file) => (
<button
key={file.name}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left"
onClick={() => openFile(file)}
>
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="text-sm font-mono font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">
{file.missing
? "缺失"
: [
file.size != null && formatSize(file.size),
file.updatedAtMs != null && formatTime(file.updatedAtMs),
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
{file.missing && (
<Badge variant="outline" className="text-xs text-yellow-600 border-yellow-500 shrink-0">
MISSING
</Badge>
)}
</button>
))}
<div className="flex items-center justify-center py-12 text-muted-foreground">
<p className="text-sm">{t("workspace.emptyFile")}</p>
</div>
)}
</ScrollArea>
@@ -213,14 +330,23 @@ export function AgentFilesSheet({ agent, open, onOpenChange }: AgentFilesSheetPr
{loadingFile ? (
<div className="flex items-center justify-center flex-1 text-muted-foreground gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">读取中...</span>
<span className="text-sm">{t("workspace.loading")}</span>
</div>
) : fileError ? (
<div className="flex items-center justify-center flex-1 text-muted-foreground">
<p className="text-xs">{fileError}</p>
</div>
) : (
<Textarea
className="flex-1 resize-none font-mono text-sm min-h-0"
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
placeholder={`${view.file.name} 内容为空,输入后点击保存即可创建`}
readOnly={!selected?.isBootstrap}
placeholder={
selected?.isBootstrap
? t("workspace.emptyFile")
: t("workspace.readonly")
}
/>
)}
</div>
-24
View File
@@ -13,15 +13,7 @@ export const LOCALE_PREFERENCE_STORAGE_KEY = "mossb-locale-preference"
export const LOCALE_OPTIONS: LocaleOption[] = [
{ code: "zh-CN", label: "简体中文", direction: "ltr" },
{ code: "zh-TW", label: "繁體中文", direction: "ltr" },
{ code: "en", label: "English", direction: "ltr" },
{ code: "ja", label: "日本語", direction: "ltr" },
{ code: "ko", label: "한국어", direction: "ltr" },
{ code: "ru", label: "Русский", direction: "ltr" },
{ code: "ar", label: "العربية", direction: "rtl" },
{ code: "hi", label: "हिन्दी", direction: "ltr" },
{ code: "fr", label: "Français", direction: "ltr" },
{ code: "vi", label: "Tiếng Việt", direction: "ltr" },
]
const LOCALE_MAP = new Map(LOCALE_OPTIONS.map((option) => [option.code, option]))
@@ -40,24 +32,8 @@ const normalizeLocale = (value: string | null | undefined): Locale | null => {
const normalized = value.trim().toLowerCase().replace(/_/g, "-")
if (
normalized === "zh-tw" ||
normalized === "zh-hk" ||
normalized === "zh-mo" ||
normalized.startsWith("zh-hant")
) {
return "zh-TW"
}
if (normalized.startsWith("zh")) return "zh-CN"
if (normalized.startsWith("en")) return "en"
if (normalized.startsWith("ja")) return "ja"
if (normalized.startsWith("ko")) return "ko"
if (normalized.startsWith("ru")) return "ru"
if (normalized.startsWith("ar")) return "ar"
if (normalized.startsWith("hi")) return "hi"
if (normalized.startsWith("fr")) return "fr"
if (normalized.startsWith("vi")) return "vi"
return null
}
-304
View File
@@ -1,304 +0,0 @@
{
"meta": {
"title": "EasiestClaw - شركة ذكاء اصطناعي افتراضية",
"description": "واجهة سهلة الاستخدام لـ OpenClaw تتيح لغير المطورين توجيه فريق ذكاء اصطناعي كما لو كانوا يتحدثون في دردشة."
},
"common": {
"cancel": "إلغاء",
"create": "إنشاء",
"creating": "جارٍ الإنشاء",
"save": "حفظ",
"saving": "جارٍ الحفظ",
"confirm": "تأكيد",
"delete": "حذف",
"deleting": "جارٍ الحذف",
"justNow": "الآن",
"me": "أنا",
"meHuman": "أنا (بشري)",
"memberCount": "{{count}} أعضاء",
"selectedCount": "{{count}} محدد",
"searchPlaceholder": "بحث..."
},
"nav": {
"chat": "الرسائل",
"virtualTeam": "الفريق الافتراضي",
"cron": "المهام المجدولة",
"settings": "الإعدادات"
},
"topNav": {
"connection": {
"connected": "متصل بـ OpenClaw",
"connecting": "جارٍ الاتصال...",
"disconnected": "غير متصل",
"error": "خطأ في الاتصال"
},
"comingSoon": "قريبًا"
},
"settings": {
"backToApp": "العودة إلى EasiestClaw",
"sections": {
"models": "إعدادات النماذج",
"gateway": "البوابة",
"language": "اللغة",
"community": "المجتمع"
},
"descriptions": {
"models": "أضف مزوّدي الذكاء الاصطناعي واضبط النماذج المتاحة",
"gateway": "تكوين عنوان اتصال بوابة OpenClaw ورمز الوصول",
"language": "يتبع لغة المتصفح افتراضيًا مع إمكانية التبديل اليدوي",
"community": "انضم إلى مجموعة WeChat الرسمية للحصول على التحديثات والدعم"
},
"community": {
"title": "المجتمع الرسمي",
"description": "امسح رمز QR أدناه للانضمام إلى مجموعة EasiestClaw الرسمية على WeChat",
"qrTip": "امسح باستخدام WeChat للانضمام"
},
"language": {
"current": "لغة الواجهة الحالية",
"system": "اتّباع لغة المتصفح",
"systemDescription": "في الزيارة الأولى سيتم استخدام اللغة المفضلة في المتصفح"
}
},
"conversationList": {
"title": "الرسائل",
"pin": "تثبيت",
"unpin": "إلغاء التثبيت",
"dissolveGroup": "حل المجموعة",
"addNewMember": "إضافة عضو جديد",
"createGroup": "إنشاء مجموعة",
"noMessages": "لا توجد رسائل بعد"
},
"newConversation": {
"title": "اختر زميلًا لبدء المحادثة",
"searchMembers": "ابحث عن أعضاء الفريق...",
"recommended": "مقترح",
"allMembers": "كل الأعضاء",
"skillsPrefix": "المهارات"
},
"createAgent": {
"gatewayUnavailable": "البوابة غير متاحة حاليًا. يرجى المحاولة لاحقًا.",
"createFailed": "فشل إنشاء Agent. حاول مرة أخرى.",
"networkError": "خطأ في الشبكة. تحقق من الاتصال ثم حاول مرة أخرى.",
"success": "تم إنشاء Agent \"{{name}}\" بنجاح",
"title": "إنشاء Agent جديد",
"description": "أضف مساعد ذكاء اصطناعي جديدًا إلى فريقك الافتراضي",
"nameLabel": "اسم Agent *",
"namePlaceholder": "أدخل اسم Agent",
"descriptionLabel": "الوصف (اختياري)",
"descriptionPlaceholder": "صف دور هذا Agent باختصار، مثل: تحليل اتجاهات السوق وبيانات المنافسين",
"descriptionHelper": "يساعد الوصف الواضح الفريق على فهم دور Agent بشكل أفضل"
},
"groupCreate": {
"createdMessage": "تم إنشاء المجموعة",
"title": "إنشاء مجموعة جديدة",
"nameLabel": "اسم المجموعة *",
"namePlaceholder": "فريق إطلاق المنتج",
"purposeLabel": "الهدف (اختياري)",
"purposePlaceholder": "تخطيط وتنسيق إطلاق المنتج في الربع الثاني، بما يشمل تحليل السوق، وكتابة المحتوى، ودعم البيانات، والجدولة",
"purposeHelper": "يساعد وصف الهدف الأعضاء على فهم سياق التعاون",
"strategyLabel": "نمط التعاون",
"maxResponders": "الحد الأقصى للمجيبين",
"coordinatorLabel": "اختر المنسق",
"coordinatorRequired": "يرجى اختيار منسق",
"selectMembers": "اختر الأعضاء",
"searchMembers": "ابحث عن أعضاء الفريق...",
"createWithCount": "إنشاء مجموعة ({{count}})"
},
"chatWindow": {
"today": "اليوم",
"yesterday": "أمس",
"emptyTitle": "اختر محادثة لبدء الدردشة",
"emptyDescription": "أو اضغط على \"محادثة جديدة\" للتحدث مع فريقك",
"notConnectedTitle": "غير متصل بـ OpenClaw",
"notConnectedDesc": "قم بالاتصال للبدء في الدردشة مع الوكلاء",
"goToOpenclaw": "انتقل إلى صفحة حالة OpenClaw",
"fullDate": "{{day}}/{{month}}/{{year}}"
},
"typingIndicator": {
"thinking": "{{name}} يفكر..."
},
"messageBubble": {
"taskStatus": {
"completed": "مكتمل",
"inProgress": "قيد التنفيذ",
"failed": "فشل"
},
"progress": "التقدم",
"scope": "النطاق: {{scope}}",
"viewDetails": "عرض التفاصيل",
"downloadReport": "تنزيل التقرير",
"download": "تنزيل",
"preview": "معاينة"
},
"messageInput": {
"placeholders": {
"withAttachments": "أضف رسالة أو واصل لصق الصور...",
"default": "اكتب رسالة... Enter للإرسال، Shift+Enter لسطر جديد، ويدعم لصق الصور"
},
"toolbar": {
"format": "تنسيق",
"mention": "إشارة",
"shortcut": "اختصار",
"attachment": "مرفق",
"more": "المزيد",
"newSession": "جلسة جديدة",
"expand": "توسيع",
"send": "إرسال"
}
},
"header": {
"status": {
"online": "متصل",
"working": "يعمل",
"busy": "مشغول",
"thinking": "يفكر"
},
"tooltips": {
"voiceCall": "مكالمة صوتية",
"videoCall": "مكالمة فيديو",
"searchMessages": "البحث في الرسائل",
"membersList": "قائمة الأعضاء"
},
"menu": {
"viewProfile": "عرض الملف الشخصي",
"rename": "إعادة التسمية",
"changeAvatar": "تغيير الصورة الرمزية",
"pinConversation": "تثبيت المحادثة",
"muteNotifications": "كتم الإشعارات",
"clearHistory": "مسح السجل",
"dissolveGroup": "حل المجموعة",
"deleteAgent": "حذف Agent"
},
"dissolveGroupConfirm": "هل أنت متأكد من حل \"{{name}}\"؟ سيتم حذف سجل الرسائل بالكامل.",
"dissolveGroupSuccess": "تم حل المجموعة \"{{name}}\"",
"deleteAgentTitle": "حذف Agent",
"deleteAgentDescription": "هل أنت متأكد من حذف \"{{name}}\"؟ سيؤدي ذلك إلى إزالة Agent وبيانات مساحة العمل الخاصة به نهائيًا.",
"deleteFailed": "فشل حذف Agent. حاول مرة أخرى.",
"deleteSuccess": "تم حذف Agent \"{{name}}\"",
"networkError": "خطأ في الشبكة. تحقق من الاتصال ثم حاول مرة أخرى.",
"renameFailed": "فشلت إعادة التسمية",
"renameSuccess": "تمت إعادة التسمية إلى \"{{name}}\"",
"renameTitle": "إعادة تسمية Agent",
"newName": "الاسم الجديد",
"newNamePlaceholder": "أدخل اسمًا جديدًا",
"selectImageFile": "يرجى اختيار ملف صورة",
"imageSizeLimit": "يجب ألا يتجاوز حجم الصورة 2 ميجابايت",
"uploadFailed": "فشل الرفع",
"avatarUpdated": "تم تحديث الصورة الرمزية",
"changeAvatarTitle": "تغيير الصورة الرمزية",
"preview": "معاينة",
"chooseAvatar": "اختر صورة رمزية",
"refreshBatch": "تحديث",
"orUploadCustom": "أو ارفع صورة رمزية مخصصة",
"clickToUpload": "اضغط لرفع صورة",
"uploadSupport": "يدعم JPG وPNG وWebP حتى 2 ميجابايت"
},
"groupMembers": {
"title": "الأعضاء ({{count}})",
"collaborationMode": "الوضع:",
"maxResponders": "حتى {{count}} أعضاء يردون في كل جولة",
"admin": "المشرف",
"addMembers": "+ إضافة أعضاء"
},
"virtualOffice": {
"boardTitle": "Q3 Goals",
"coffeeMachine": "Coffee Machine",
"statusBar": "{{working}} working · {{idle}} idle",
"viewTeamPanel": "View Team Panel >>"
},
"cronView": {
"title": "Scheduled Jobs",
"refresh": "Refresh",
"status": "Status:",
"enabled": "Enabled",
"disabled": "Disabled",
"jobCount": "Jobs:",
"nextWake": "Next wake:",
"jobList": "Job List",
"showCount": "Showing {{shown}} / Total {{total}}",
"loading": "Loading...",
"empty": "No scheduled jobs",
"expired": "expired"
},
"modelConfig": {
"loadFailed": "Failed to load model config: ",
"saved": "Model config saved",
"saveFailed": "Save failed: ",
"unknownError": "Unknown error",
"loading": "Loading...",
"activeConfig": "Active Configuration",
"primaryModel": "Primary model:",
"notSelected": "-- Not selected --",
"noApiKey": " (No API Key)",
"current": "Current:",
"searchPlaceholder": "Search models (filter by ID or name)",
"noMatch": "No matching providers or models",
"noProviders": "No providers yet, please add one",
"saving": "Saving...",
"saveConfig": "Save Config",
"modelCount": "{{count}} models",
"available": "Available {{ms}}ms",
"unavailable": "Unavailable",
"apiType": "API Type",
"note": "Note",
"notePlaceholder": "e.g. Company account / Personal test",
"modelList": "Model List",
"modelIdPlaceholder": "Model ID",
"customModel": "Custom Model",
"deleteConfirm": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone until you save.",
"editProvider": "Edit {{name}}",
"editProviderDesc": "Configure provider connection and manage model list",
"addModel": "Add Model",
"suggestedModels": "Suggested Models",
"done": "Done",
"reasoning": "Reasoning",
"vision": "رؤية",
"providerId": "Provider ID",
"addProvider": "Add Provider",
"addProviderDesc": "Choose a known AI provider for quick setup, or create a custom one",
"searchProviderPlaceholder": "Search providers...",
"addCustomProvider": "Custom Provider",
"providerCategoryCn": "China",
"providerCategoryIntl": "International",
"providerCategoryAgg": "Aggregation",
"alreadyAdded": "Added"
},
"orchestration": {
"strategy": {
"skill-match": {
"label": "مطابقة ذكية",
"description": "يوجه كل سؤال تلقائيًا إلى الأعضاء الأكثر صلة"
},
"coordinator": {
"label": "المنسق",
"description": "يقوم منسق واحد بتحليل المهمة وتعيين أفضل المشاركين"
},
"round-robin": {
"label": "بالتناوب",
"description": "يرد الأعضاء بالتناوب على كل طلب"
},
"all": {
"label": "الجميع يرد",
"description": "يرد جميع الأعضاء على كل طلب"
}
}
},
"changelog": {
"title": "سجل التغييرات"
},
"onboarding": {
"title": "مرحباً بك في EasiestClaw AI",
"subtitle": "إعداد سريع حتى يتعرف عليك فريق الذكاء الاصطناعي",
"avatarSectionTitle": "اختر صورة رمزية",
"nameSectionTitle": "اسمك",
"namePlaceholder": "أدخل اسمك",
"getStarted": "ابدأ الآن",
"skip": "تخطي لاحقاً",
"nameRequired": "الرجاء إدخال اسمك أولاً",
"extracting": "جارٍ فك ضغط OpenClaw...",
"gatewayStarting": "جارٍ تشغيل OpenClaw...",
"gatewayStartingDesc": "قد يستغرق التشغيل الأول بضع ثوانٍ. إذا ظهرت مطالبة جدار الحماية، انقر على \"السماح\"",
"gatewayConnecting": "جارٍ الاتصال بـ Gateway...",
"gatewayReady": "Gateway جاهز، جارٍ التحميل..."
}
}
+47 -3
View File
@@ -88,7 +88,8 @@
"createGroupComingSoon": "Coming soon. Stay tuned.",
"noMessages": "No messages yet",
"refreshSuccess": "Refreshed",
"refreshFailed": "Refresh failed, please try again"
"refreshFailed": "Refresh failed, please try again",
"loadingAgents": "Loading team members..."
},
"newConversation": {
"title": "Choose a teammate to start chatting",
@@ -364,7 +365,12 @@
"dataLocationChooseHint": "Click to browse and select a folder",
"dataLocationDefault": "Use Default Location",
"dataLocationDefaultHint": "Data will be stored in the system app data directory",
"dataLocationConfirm": "Confirm & Continue"
"dataLocationConfirm": "Confirm & Continue",
"modelCheckTitle": "Configure AI Model",
"modelCheckDesc": "No model configuration detected. You need to configure at least one AI model to use the app.",
"modelCheckConfigure": "Configure Now",
"modelCheckSkip": "Configure Later",
"modelCheckSkipHint": "You can configure models anytime in Settings"
},
"windowControls": {
"minimize": "Minimize",
@@ -384,7 +390,11 @@
"readonly": "Read-only",
"selectFile": "Select a file to view",
"emptyFile": "File is empty. Type something and save.",
"emptyMemory": "No content"
"emptyMemory": "No content",
"refresh": "Refresh",
"binaryFile": "Binary file, cannot preview",
"fileTooLarge": "File too large to preview",
"openExternal": "Open with default app"
},
"channels": {
"title": "Channels",
@@ -432,5 +442,39 @@
"streamingBlock": "Block",
"streamingProgress": "Progress"
}
},
"skills": {
"title": "Skills",
"subtitle": "Global skills management",
"tabInstalled": "Installed",
"tabMarketplace": "Marketplace",
"installed": "Installed Skills",
"refresh": "Refresh",
"loading": "Loading...",
"noSkills": "No skills available",
"retry": "Retry",
"enable": "Click to enable",
"disable": "Click to disable",
"searchPlaceholder": "Search skills...",
"searching": "Searching...",
"noResults": "No matching skills found",
"tryOther": "Try different keywords",
"install": "Install",
"installing": "Installing...",
"installed_badge": "Installed",
"installSuccess": "Skill installed successfully",
"installFailed": "Installation failed",
"version": "v{{version}}",
"score": "Relevance",
"by": "by",
"downloads": "downloads",
"explore": "Trending",
"exploreHint": "Search for skills on ClawHub marketplace",
"marketplaceError": "Failed to fetch marketplace data",
"openClawHub": "Browse ClawHub",
"changelog": "Changelog",
"viewOnClawHub": "View on ClawHub",
"malwareBlocked": "This skill has been blocked for malware",
"suspicious": "This skill has been flagged as suspicious"
}
}
-304
View File
@@ -1,304 +0,0 @@
{
"meta": {
"title": "EasiestClaw - entreprise virtuelle IA",
"description": "Une interface conviviale pour OpenClaw permettant de piloter une équipe IA aussi naturellement qu’une conversation."
},
"common": {
"cancel": "Annuler",
"create": "Créer",
"creating": "Création",
"save": "Enregistrer",
"saving": "Enregistrement",
"confirm": "Confirmer",
"delete": "Supprimer",
"deleting": "Suppression",
"justNow": "À l'instant",
"me": "Moi",
"meHuman": "Moi (humain)",
"memberCount": "{{count}} membres",
"selectedCount": "{{count}} sélectionnés",
"searchPlaceholder": "Rechercher..."
},
"nav": {
"chat": "Messages",
"virtualTeam": "Équipe virtuelle",
"cron": "Tâches planifiées",
"settings": "Paramètres"
},
"topNav": {
"connection": {
"connected": "Connecté à OpenClaw",
"connecting": "Connexion...",
"disconnected": "Déconnecté",
"error": "Erreur de connexion"
},
"comingSoon": "Bientôt disponible"
},
"settings": {
"backToApp": "Retour à EasiestClaw",
"sections": {
"models": "Config modèles",
"gateway": "Passerelle",
"language": "Langue",
"community": "Communauté"
},
"descriptions": {
"models": "Ajoutez des fournisseurs IA et configurez les modèles disponibles",
"gateway": "Configurer l'URL de connexion et le jeton d'accès de la passerelle OpenClaw",
"language": "Suit la langue du navigateur par défaut, avec possibilité de changement manuel",
"community": "Rejoignez le groupe WeChat officiel pour les dernières nouvelles et le support"
},
"community": {
"title": "Communauté officielle",
"description": "Scannez le code QR ci-dessous pour rejoindre le groupe WeChat officiel EasiestClaw",
"qrTip": "Scannez avec WeChat pour rejoindre"
},
"language": {
"current": "Langue actuelle de l'interface",
"system": "Suivre le navigateur",
"systemDescription": "Lors de la première visite, la langue préférée du navigateur est utilisée"
}
},
"conversationList": {
"title": "Messages",
"pin": "Épingler",
"unpin": "Désépingler",
"dissolveGroup": "Dissoudre le groupe",
"addNewMember": "Ajouter un membre",
"createGroup": "Créer un groupe",
"noMessages": "Aucun message pour le moment"
},
"newConversation": {
"title": "Choisissez un coéquipier pour démarrer une discussion",
"searchMembers": "Rechercher des membres...",
"recommended": "Recommandés",
"allMembers": "Tous les membres",
"skillsPrefix": "Compétences"
},
"createAgent": {
"gatewayUnavailable": "Passerelle indisponible. Veuillez réessayer plus tard.",
"createFailed": "Impossible de créer le Agent. Veuillez réessayer.",
"networkError": "Erreur réseau. Vérifiez votre connexion puis réessayez.",
"success": "Agent \"{{name}}\" créé avec succès",
"title": "Créer un nouveau Agent",
"description": "Ajoutez un nouvel assistant IA à votre équipe virtuelle",
"nameLabel": "Nom du Agent *",
"namePlaceholder": "Saisissez le nom du Agent",
"descriptionLabel": "Description (facultatif)",
"descriptionPlaceholder": "Décrivez brièvement le rôle de ce Agent, par exemple : analyse les tendances du marché et les données concurrentielles",
"descriptionHelper": "Une description claire aide l’équipe à comprendre le rôle du Agent"
},
"groupCreate": {
"createdMessage": "Groupe créé",
"title": "Créer un nouveau groupe",
"nameLabel": "Nom du groupe *",
"namePlaceholder": "Équipe de lancement produit",
"purposeLabel": "Objectif (facultatif)",
"purposePlaceholder": "Planifier et coordonner le lancement produit du T2, y compris analyse de marché, rédaction, support data et planning",
"purposeHelper": "La description de l’objectif aide les membres à comprendre le contexte de collaboration",
"strategyLabel": "Mode de collaboration",
"maxResponders": "Nombre max. de répondants",
"coordinatorLabel": "Choisir un coordinateur",
"coordinatorRequired": "Veuillez choisir un coordinateur",
"selectMembers": "Choisir les membres",
"searchMembers": "Rechercher des membres...",
"createWithCount": "Créer le groupe ({{count}})"
},
"chatWindow": {
"today": "Aujourd'hui",
"yesterday": "Hier",
"emptyTitle": "Choisissez une conversation pour commencer",
"emptyDescription": "Ou cliquez sur « Nouvelle conversation » pour parler à votre équipe",
"notConnectedTitle": "Non connecté à OpenClaw",
"notConnectedDesc": "Connectez-vous pour discuter avec les agents",
"goToOpenclaw": "Accéder à l'état OpenClaw",
"fullDate": "{{day}}/{{month}}/{{year}}"
},
"typingIndicator": {
"thinking": "{{name}} réfléchit..."
},
"messageBubble": {
"taskStatus": {
"completed": "Terminé",
"inProgress": "En cours",
"failed": "Échec"
},
"progress": "Progression",
"scope": "Portée : {{scope}}",
"viewDetails": "Voir les détails",
"downloadReport": "Télécharger le rapport",
"download": "Télécharger",
"preview": "Aperçu"
},
"messageInput": {
"placeholders": {
"withAttachments": "Ajoutez un message ou continuez à coller des images...",
"default": "Saisissez un message... Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne, collage d’images pris en charge"
},
"toolbar": {
"format": "Format",
"mention": "Mention",
"shortcut": "Raccourci",
"attachment": "Pièce jointe",
"more": "Plus",
"newSession": "Nouvelle session",
"expand": "Agrandir",
"send": "Envoyer"
}
},
"header": {
"status": {
"online": "En ligne",
"working": "En cours",
"busy": "Occupé",
"thinking": "Réfléchit"
},
"tooltips": {
"voiceCall": "Appel audio",
"videoCall": "Appel vidéo",
"searchMessages": "Rechercher dans les messages",
"membersList": "Liste des membres"
},
"menu": {
"viewProfile": "Voir le profil",
"rename": "Renommer",
"changeAvatar": "Changer l'avatar",
"pinConversation": "Épingler la conversation",
"muteNotifications": "Couper les notifications",
"clearHistory": "Effacer l'historique",
"dissolveGroup": "Dissoudre le groupe",
"deleteAgent": "Supprimer le Agent"
},
"dissolveGroupConfirm": "Voulez-vous vraiment dissoudre « {{name}} » ? Tout l’historique des messages sera supprimé.",
"dissolveGroupSuccess": "Le groupe « {{name}} » a été dissous",
"deleteAgentTitle": "Supprimer le Agent",
"deleteAgentDescription": "Voulez-vous vraiment supprimer « {{name}} » ? Le Agent et ses données d’espace de travail seront supprimés définitivement.",
"deleteFailed": "Échec de la suppression du Agent. Veuillez réessayer.",
"deleteSuccess": "Agent \"{{name}}\" supprimé",
"networkError": "Erreur réseau. Vérifiez votre connexion puis réessayez.",
"renameFailed": "Échec du renommage",
"renameSuccess": "Renommé en « {{name}} »",
"renameTitle": "Renommer le Agent",
"newName": "Nouveau nom",
"newNamePlaceholder": "Saisissez un nouveau nom",
"selectImageFile": "Veuillez choisir un fichier image",
"imageSizeLimit": "La taille de l’image doit être inférieure à 2 Mo",
"uploadFailed": "Échec du téléversement",
"avatarUpdated": "Avatar mis à jour",
"changeAvatarTitle": "Changer l'avatar",
"preview": "Aperçu",
"chooseAvatar": "Choisir un avatar",
"refreshBatch": "Rafraîchir",
"orUploadCustom": "Ou importer un avatar personnalisé",
"clickToUpload": "Cliquez pour importer une image",
"uploadSupport": "JPG, PNG, WebP pris en charge, 2 Mo max"
},
"groupMembers": {
"title": "Membres ({{count}})",
"collaborationMode": "Mode :",
"maxResponders": "Jusqu’à {{count}} membres répondent à chaque tour",
"admin": "Admin",
"addMembers": "+ Ajouter des membres"
},
"virtualOffice": {
"boardTitle": "Q3 Goals",
"coffeeMachine": "Coffee Machine",
"statusBar": "{{working}} working · {{idle}} idle",
"viewTeamPanel": "View Team Panel >>"
},
"cronView": {
"title": "Scheduled Jobs",
"refresh": "Refresh",
"status": "Status:",
"enabled": "Enabled",
"disabled": "Disabled",
"jobCount": "Jobs:",
"nextWake": "Next wake:",
"jobList": "Job List",
"showCount": "Showing {{shown}} / Total {{total}}",
"loading": "Loading...",
"empty": "No scheduled jobs",
"expired": "expired"
},
"modelConfig": {
"loadFailed": "Failed to load model config: ",
"saved": "Model config saved",
"saveFailed": "Save failed: ",
"unknownError": "Unknown error",
"loading": "Loading...",
"activeConfig": "Active Configuration",
"primaryModel": "Primary model:",
"notSelected": "-- Not selected --",
"noApiKey": " (No API Key)",
"current": "Current:",
"searchPlaceholder": "Search models (filter by ID or name)",
"noMatch": "No matching providers or models",
"noProviders": "No providers yet, please add one",
"saving": "Saving...",
"saveConfig": "Save Config",
"modelCount": "{{count}} models",
"available": "Available {{ms}}ms",
"unavailable": "Unavailable",
"apiType": "API Type",
"note": "Note",
"notePlaceholder": "e.g. Company account / Personal test",
"modelList": "Model List",
"modelIdPlaceholder": "Model ID",
"customModel": "Custom Model",
"deleteConfirm": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone until you save.",
"editProvider": "Edit {{name}}",
"editProviderDesc": "Configure provider connection and manage model list",
"addModel": "Add Model",
"suggestedModels": "Suggested Models",
"done": "Done",
"reasoning": "Reasoning",
"vision": "Vision",
"providerId": "Provider ID",
"addProvider": "Add Provider",
"addProviderDesc": "Choose a known AI provider for quick setup, or create a custom one",
"searchProviderPlaceholder": "Search providers...",
"addCustomProvider": "Custom Provider",
"providerCategoryCn": "China",
"providerCategoryIntl": "International",
"providerCategoryAgg": "Aggregation",
"alreadyAdded": "Added"
},
"orchestration": {
"strategy": {
"skill-match": {
"label": "Correspondance intelligente",
"description": "Oriente automatiquement chaque question vers les membres les plus pertinents"
},
"coordinator": {
"label": "Coordinateur",
"description": "Un coordinateur analyse la tâche et désigne les meilleurs intervenants"
},
"round-robin": {
"label": "Tour à tour",
"description": "Les membres répondent chacun leur tour à chaque demande"
},
"all": {
"label": "Tout le monde répond",
"description": "Chaque membre répond à chaque demande"
}
}
},
"changelog": {
"title": "Journal des modifications"
},
"onboarding": {
"title": "Bienvenue dans EasiestClaw AI",
"subtitle": "Quelques réglages rapides pour que votre équipe IA vous connaisse",
"avatarSectionTitle": "Choisir un avatar",
"nameSectionTitle": "Votre nom",
"namePlaceholder": "Entrez votre nom",
"getStarted": "Commencer",
"skip": "Passer pour l'instant",
"nameRequired": "Veuillez d'abord entrer votre nom",
"extracting": "Décompression d'OpenClaw...",
"gatewayStarting": "Démarrage d'OpenClaw...",
"gatewayStartingDesc": "Le premier démarrage peut prendre quelques secondes. Si une invite de pare-feu apparaît, cliquez sur « Autoriser »",
"gatewayConnecting": "Connexion au Gateway...",
"gatewayReady": "Gateway prêt, chargement..."
}
}
-304
View File
@@ -1,304 +0,0 @@
{
"meta": {
"title": "EasiestClaw - AI वर्चुअल कंपनी",
"description": "OpenClaw के लिए एक उपयोगकर्ता-मित्र इंटरफ़ेस, जिससे गैर-प्रोग्रामर भी चैट की तरह AI टीम को निर्देश दे सकें।"
},
"common": {
"cancel": "रद्द करें",
"create": "बनाएँ",
"creating": "बनाया जा रहा है",
"save": "सहेजें",
"saving": "सहेजा जा रहा है",
"confirm": "पुष्टि करें",
"delete": "हटाएँ",
"deleting": "हटाया जा रहा है",
"justNow": "अभी",
"me": "मैं",
"meHuman": "मैं (मानव)",
"memberCount": "{{count}} सदस्य",
"selectedCount": "{{count}} चुने गए",
"searchPlaceholder": "खोजें..."
},
"nav": {
"chat": "संदेश",
"virtualTeam": "वर्चुअल टीम",
"cron": "निर्धारित कार्य",
"settings": "सेटिंग्स"
},
"topNav": {
"connection": {
"connected": "OpenClaw से जुड़ा",
"connecting": "जुड़ रहा है...",
"disconnected": "कनेक्ट नहीं है",
"error": "कनेक्शन त्रुटि"
},
"comingSoon": "जल्द आ रहा है"
},
"settings": {
"backToApp": "EasiestClaw पर वापस जाएँ",
"sections": {
"models": "मॉडल कॉन्फ़िग",
"gateway": "गेटवे",
"language": "भाषा",
"community": "समुदाय"
},
"descriptions": {
"models": "AI प्रदाताओं को जोड़ें और उपलब्ध मॉडलों को कॉन्फ़िगर करें",
"gateway": "OpenClaw गेटवे कनेक्शन URL और एक्सेस टोकन कॉन्फ़िगर करें",
"language": "डिफ़ॉल्ट रूप से ब्राउज़र भाषा का पालन करती है, लेकिन आप इसे हाथ से बदल सकते हैं",
"community": "अपडेट और सहायता के लिए आधिकारिक WeChat समूह में शामिल हों"
},
"community": {
"title": "आधिकारिक समुदाय",
"description": "EasiestClaw आधिकारिक WeChat समूह में शामिल होने के लिए नीचे QR कोड स्कैन करें",
"qrTip": "शामिल होने के लिए WeChat से स्कैन करें"
},
"language": {
"current": "वर्तमान इंटरफ़ेस भाषा",
"system": "ब्राउज़र भाषा के अनुसार",
"systemDescription": "पहली बार खोलने पर ब्राउज़र की पसंदीदा भाषा का उपयोग किया जाएगा"
}
},
"conversationList": {
"title": "संदेश",
"pin": "पिन करें",
"unpin": "अनपिन करें",
"dissolveGroup": "समूह समाप्त करें",
"addNewMember": "नया सदस्य जोड़ें",
"createGroup": "समूह बनाएँ",
"noMessages": "अभी तक कोई संदेश नहीं"
},
"newConversation": {
"title": "चैट शुरू करने के लिए एक टीम सदस्य चुनें",
"searchMembers": "टीम सदस्य खोजें...",
"recommended": "सुझावित",
"allMembers": "सभी सदस्य",
"skillsPrefix": "विशेषज्ञता"
},
"createAgent": {
"gatewayUnavailable": "गेटवे उपलब्ध नहीं है। कृपया बाद में पुनः प्रयास करें।",
"createFailed": "Agent बनाने में विफल। कृपया फिर से प्रयास करें।",
"networkError": "नेटवर्क त्रुटि। कृपया कनेक्शन जाँचें और फिर से प्रयास करें।",
"success": "Agent \"{{name}}\" सफलतापूर्वक बनाया गया",
"title": "नया Agent बनाएँ",
"description": "अपनी वर्चुअल टीम में नया AI साथी जोड़ें",
"nameLabel": "Agent नाम *",
"namePlaceholder": "Agent का नाम दर्ज करें",
"descriptionLabel": "विवरण (वैकल्पिक)",
"descriptionPlaceholder": "इस Agent की भूमिका संक्षेप में बताइए, जैसे: बाज़ार रुझान और प्रतियोगी डेटा का विश्लेषण करता है",
"descriptionHelper": "स्पष्ट विवरण टीम को Agent की भूमिका समझने में मदद करता है"
},
"groupCreate": {
"createdMessage": "समूह बन गया",
"title": "नया समूह बनाएँ",
"nameLabel": "समूह का नाम *",
"namePlaceholder": "प्रोडक्ट लॉन्च टीम",
"purposeLabel": "उद्देश्य (वैकल्पिक)",
"purposePlaceholder": "Q2 प्रोडक्ट लॉन्च की योजना और समन्वय, जिसमें बाज़ार विश्लेषण, कॉपीराइटिंग, डेटा समर्थन और शेड्यूलिंग शामिल है",
"purposeHelper": "उद्देश्य बताने से टीम को सहयोग का संदर्भ समझने में आसानी होती है",
"strategyLabel": "सहयोग मोड",
"maxResponders": "अधिकतम उत्तरदाता",
"coordinatorLabel": "समन्वयक चुनें",
"coordinatorRequired": "कृपया एक समन्वयक चुनें",
"selectMembers": "सदस्य चुनें",
"searchMembers": "टीम सदस्य खोजें...",
"createWithCount": "समूह बनाएँ ({{count}})"
},
"chatWindow": {
"today": "आज",
"yesterday": "कल",
"emptyTitle": "चैट शुरू करने के लिए एक बातचीत चुनें",
"emptyDescription": "या अपनी टीम से बात करने के लिए \"नई बातचीत\" पर क्लिक करें",
"notConnectedTitle": "OpenClaw से कनेक्ट नहीं है",
"notConnectedDesc": "एजेंट के साथ चैट करने के लिए कनेक्ट करें",
"goToOpenclaw": "OpenClaw स्टेटस पेज पर जाएं",
"fullDate": "{{day}}/{{month}}/{{year}}"
},
"typingIndicator": {
"thinking": "{{name}} सोच रहा/रही है..."
},
"messageBubble": {
"taskStatus": {
"completed": "पूर्ण",
"inProgress": "प्रगति पर",
"failed": "विफल"
},
"progress": "प्रगति",
"scope": "दायरा: {{scope}}",
"viewDetails": "विवरण देखें",
"downloadReport": "रिपोर्ट डाउनलोड करें",
"download": "डाउनलोड",
"preview": "पूर्वावलोकन"
},
"messageInput": {
"placeholders": {
"withAttachments": "संदेश जोड़ें या चित्र पेस्ट करना जारी रखें...",
"default": "संदेश लिखें... भेजने के लिए Enter, नई पंक्ति के लिए Shift+Enter, चित्र पेस्ट करना समर्थित है"
},
"toolbar": {
"format": "फ़ॉर्मैट",
"mention": "मेंशन",
"shortcut": "शॉर्टकट",
"attachment": "अटैचमेंट",
"more": "और",
"newSession": "नई सेशन",
"expand": "विस्तार करें",
"send": "भेजें"
}
},
"header": {
"status": {
"online": "ऑनलाइन",
"working": "काम कर रहा है",
"busy": "व्यस्त",
"thinking": "सोच रहा है"
},
"tooltips": {
"voiceCall": "वॉइस कॉल",
"videoCall": "वीडियो कॉल",
"searchMessages": "संदेश खोजें",
"membersList": "सदस्य सूची"
},
"menu": {
"viewProfile": "प्रोफ़ाइल देखें",
"rename": "नाम बदलें",
"changeAvatar": "अवतार बदलें",
"pinConversation": "बातचीत पिन करें",
"muteNotifications": "सूचनाएँ म्यूट करें",
"clearHistory": "इतिहास साफ़ करें",
"dissolveGroup": "समूह समाप्त करें",
"deleteAgent": "Agent हटाएँ"
},
"dissolveGroupConfirm": "क्या आप वाकई \"{{name}}\" समूह समाप्त करना चाहते हैं? सभी संदेश इतिहास हट जाएंगे।",
"dissolveGroupSuccess": "समूह \"{{name}}\" समाप्त कर दिया गया है",
"deleteAgentTitle": "Agent हटाएँ",
"deleteAgentDescription": "क्या आप वाकई \"{{name}}\" को हटाना चाहते हैं? इससे Agent और उसका कार्यक्षेत्र डेटा स्थायी रूप से हट जाएगा।",
"deleteFailed": "Agent हटाने में विफल। कृपया फिर से प्रयास करें।",
"deleteSuccess": "Agent \"{{name}}\" हटाया गया",
"networkError": "नेटवर्क त्रुटि। कृपया कनेक्शन जाँचें और फिर से प्रयास करें।",
"renameFailed": "नाम बदलने में विफल",
"renameSuccess": "नाम बदलकर \"{{name}}\" कर दिया गया",
"renameTitle": "Agent का नाम बदलें",
"newName": "नया नाम",
"newNamePlaceholder": "नया नाम दर्ज करें",
"selectImageFile": "कृपया एक चित्र फ़ाइल चुनें",
"imageSizeLimit": "चित्र का आकार 2MB से कम होना चाहिए",
"uploadFailed": "अपलोड विफल",
"avatarUpdated": "अवतार अपडेट हो गया",
"changeAvatarTitle": "अवतार बदलें",
"preview": "पूर्वावलोकन",
"chooseAvatar": "अवतार चुनें",
"refreshBatch": "ताज़ा करें",
"orUploadCustom": "या अपना कस्टम अवतार अपलोड करें",
"clickToUpload": "चित्र अपलोड करने के लिए क्लिक करें",
"uploadSupport": "JPG, PNG, WebP समर्थित, अधिकतम 2MB"
},
"groupMembers": {
"title": "सदस्य ({{count}})",
"collaborationMode": "मोड:",
"maxResponders": "हर राउंड में अधिकतम {{count}} सदस्य उत्तर देंगे",
"admin": "व्यवस्थापक",
"addMembers": "+ सदस्य जोड़ें"
},
"virtualOffice": {
"boardTitle": "Q3 Goals",
"coffeeMachine": "Coffee Machine",
"statusBar": "{{working}} working · {{idle}} idle",
"viewTeamPanel": "View Team Panel >>"
},
"cronView": {
"title": "Scheduled Jobs",
"refresh": "Refresh",
"status": "Status:",
"enabled": "Enabled",
"disabled": "Disabled",
"jobCount": "Jobs:",
"nextWake": "Next wake:",
"jobList": "Job List",
"showCount": "Showing {{shown}} / Total {{total}}",
"loading": "Loading...",
"empty": "No scheduled jobs",
"expired": "expired"
},
"modelConfig": {
"loadFailed": "Failed to load model config: ",
"saved": "Model config saved",
"saveFailed": "Save failed: ",
"unknownError": "Unknown error",
"loading": "Loading...",
"activeConfig": "Active Configuration",
"primaryModel": "Primary model:",
"notSelected": "-- Not selected --",
"noApiKey": " (No API Key)",
"current": "Current:",
"searchPlaceholder": "Search models (filter by ID or name)",
"noMatch": "No matching providers or models",
"noProviders": "No providers yet, please add one",
"saving": "Saving...",
"saveConfig": "Save Config",
"modelCount": "{{count}} models",
"available": "Available {{ms}}ms",
"unavailable": "Unavailable",
"apiType": "API Type",
"note": "Note",
"notePlaceholder": "e.g. Company account / Personal test",
"modelList": "Model List",
"modelIdPlaceholder": "Model ID",
"customModel": "Custom Model",
"deleteConfirm": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone until you save.",
"editProvider": "Edit {{name}}",
"editProviderDesc": "Configure provider connection and manage model list",
"addModel": "Add Model",
"suggestedModels": "Suggested Models",
"done": "Done",
"reasoning": "Reasoning",
"vision": "दृष्टि",
"providerId": "Provider ID",
"addProvider": "Add Provider",
"addProviderDesc": "Choose a known AI provider for quick setup, or create a custom one",
"searchProviderPlaceholder": "Search providers...",
"addCustomProvider": "Custom Provider",
"providerCategoryCn": "China",
"providerCategoryIntl": "International",
"providerCategoryAgg": "Aggregation",
"alreadyAdded": "Added"
},
"orchestration": {
"strategy": {
"skill-match": {
"label": "स्मार्ट मैच",
"description": "हर प्रश्न को सबसे उपयुक्त सदस्यों तक अपने आप पहुँचाता है"
},
"coordinator": {
"label": "समन्वयक",
"description": "एक समन्वयक कार्य का विश्लेषण करके सबसे उपयुक्त उत्तरदाताओं को चुनता है"
},
"round-robin": {
"label": "राउंड रॉबिन",
"description": "सदस्य बारी-बारी से हर अनुरोध का उत्तर देते हैं"
},
"all": {
"label": "सभी उत्तर दें",
"description": "हर अनुरोध पर सभी सदस्य उत्तर देते हैं"
}
}
},
"changelog": {
"title": "परिवर्तन लॉग"
},
"onboarding": {
"title": "EasiestClaw AI में आपका स्वागत है",
"subtitle": "त्वरित सेटअप ताकि आपकी AI टीम आपको जान सके",
"avatarSectionTitle": "अवतार चुनें",
"nameSectionTitle": "आपका नाम",
"namePlaceholder": "अपना नाम दर्ज करें",
"getStarted": "शुरू करें",
"skip": "अभी के लिए छोड़ें",
"nameRequired": "कृपया पहले अपना नाम दर्ज करें",
"extracting": "OpenClaw निकाला जा रहा है...",
"gatewayStarting": "OpenClaw प्रारंभ हो रहा है...",
"gatewayStartingDesc": "पहली बार प्रारंभ होने में कुछ सेकंड लग सकते हैं। यदि फ़ायरवॉल संकेत दिखे, तो \"अनुमति दें\" पर क्लिक करें",
"gatewayConnecting": "Gateway से कनेक्ट हो रहा है...",
"gatewayReady": "Gateway तैयार है, लोड हो रहा है..."
}
}
-304
View File
@@ -1,304 +0,0 @@
{
"meta": {
"title": "EasiestClaw - AI仮想カンパニー",
"description": "OpenClaw の親しみやすい操作シェル。非エンジニアでもチャット感覚で AI チームに指示できます。"
},
"common": {
"cancel": "キャンセル",
"create": "作成",
"creating": "作成中",
"save": "保存",
"saving": "保存中",
"confirm": "確認",
"delete": "削除",
"deleting": "削除中",
"justNow": "たった今",
"me": "私",
"meHuman": "私(人間)",
"memberCount": "{{count}}人のメンバー",
"selectedCount": "{{count}}件選択",
"searchPlaceholder": "検索..."
},
"nav": {
"chat": "メッセージ",
"virtualTeam": "バーチャルチーム",
"cron": "定期タスク",
"settings": "設定"
},
"topNav": {
"connection": {
"connected": "OpenClaw に接続済み",
"connecting": "接続中...",
"disconnected": "未接続",
"error": "接続エラー"
},
"comingSoon": "近日対応"
},
"settings": {
"backToApp": "EasiestClaw に戻る",
"sections": {
"models": "モデル設定",
"gateway": "ゲートウェイ",
"language": "言語",
"community": "コミュニティ"
},
"descriptions": {
"models": "AI プロバイダーを追加し、利用可能なモデルを設定します",
"gateway": "OpenClaw ゲートウェイの接続先とアクセストークンを設定",
"language": "既定ではブラウザの言語に従い、手動で切り替えることもできます",
"community": "公式WeChatグループに参加して最新情報やサポートを受けましょう"
},
"community": {
"title": "公式コミュニティ",
"description": "下のQRコードをスキャンしてEasiestClaw公式WeChatグループに参加しましょう",
"qrTip": "WeChatでスキャンして参加"
},
"language": {
"current": "現在の表示言語",
"system": "ブラウザに合わせる",
"systemDescription": "初回アクセス時はブラウザの優先言語を使用します"
}
},
"conversationList": {
"title": "メッセージ",
"pin": "ピン留め",
"unpin": "ピン留め解除",
"dissolveGroup": "グループを解散",
"addNewMember": "新しいメンバーを追加",
"createGroup": "グループを作成",
"noMessages": "まだメッセージはありません"
},
"newConversation": {
"title": "チャットを始めるメンバーを選択",
"searchMembers": "チームメンバーを検索...",
"recommended": "おすすめ",
"allMembers": "すべてのメンバー",
"skillsPrefix": "得意分野"
},
"createAgent": {
"gatewayUnavailable": "ゲートウェイを利用できません。しばらくしてから再試行してください。",
"createFailed": "Agent の作成に失敗しました。もう一度お試しください。",
"networkError": "ネットワークエラーです。接続を確認して再試行してください。",
"success": "Agent \"{{name}}\" を作成しました",
"title": "新しい Agent を作成",
"description": "バーチャルチームに新しい AI メンバーを追加します",
"nameLabel": "Agent 名 *",
"namePlaceholder": "Agent 名を入力",
"descriptionLabel": "説明(任意)",
"descriptionPlaceholder": "この Agent の役割を簡単に説明してください。例:市場動向と競合データを分析する",
"descriptionHelper": "説明があると、チームが Agent の役割を理解しやすくなります"
},
"groupCreate": {
"createdMessage": "グループを作成しました",
"title": "新しいグループを作成",
"nameLabel": "グループ名 *",
"namePlaceholder": "プロダクトローンチチーム",
"purposeLabel": "目的(任意)",
"purposePlaceholder": "Q2 のプロダクトローンチを計画・調整します。市場分析、コピー、データ支援、スケジュール管理を含みます。",
"purposeHelper": "目的があると、メンバーが協業の文脈を理解しやすくなります",
"strategyLabel": "協業モード",
"maxResponders": "最大応答人数",
"coordinatorLabel": "コーディネーターを選択",
"coordinatorRequired": "コーディネーターを選択してください",
"selectMembers": "メンバーを選択",
"searchMembers": "チームメンバーを検索...",
"createWithCount": "グループを作成 ({{count}})"
},
"chatWindow": {
"today": "今日",
"yesterday": "昨日",
"emptyTitle": "会話を選んでチャットを始めましょう",
"emptyDescription": "または「新しい会話」をクリックしてチームと話しましょう",
"notConnectedTitle": "OpenClaw に接続されていません",
"notConnectedDesc": "接続するとエージェントとチャットできます",
"goToOpenclaw": "OpenClaw ステータスページへ",
"fullDate": "{{year}}/{{month}}/{{day}}"
},
"typingIndicator": {
"thinking": "{{name}} が考え中..."
},
"messageBubble": {
"taskStatus": {
"completed": "完了",
"inProgress": "進行中",
"failed": "失敗"
},
"progress": "進捗",
"scope": "範囲: {{scope}}",
"viewDetails": "詳細を見る",
"downloadReport": "レポートをダウンロード",
"download": "ダウンロード",
"preview": "プレビュー"
},
"messageInput": {
"placeholders": {
"withAttachments": "メッセージを追加するか、画像を貼り付け続けてください...",
"default": "メッセージを入力... Enter で送信、Shift+Enter で改行、画像の貼り付けに対応"
},
"toolbar": {
"format": "書式",
"mention": "メンション",
"shortcut": "ショートカット",
"attachment": "添付",
"more": "その他",
"newSession": "新しいセッション",
"expand": "展開",
"send": "送信"
}
},
"header": {
"status": {
"online": "オンライン",
"working": "作業中",
"busy": "取り込み中",
"thinking": "思考中"
},
"tooltips": {
"voiceCall": "音声通話",
"videoCall": "ビデオ通話",
"searchMessages": "メッセージを検索",
"membersList": "メンバー一覧"
},
"menu": {
"viewProfile": "プロフィールを見る",
"rename": "名前を変更",
"changeAvatar": "アバターを変更",
"pinConversation": "会話をピン留め",
"muteNotifications": "通知をミュート",
"clearHistory": "履歴を消去",
"dissolveGroup": "グループを解散",
"deleteAgent": "Agent を削除"
},
"dissolveGroupConfirm": "「{{name}}」を解散しますか?すべてのメッセージ履歴が削除されます。",
"dissolveGroupSuccess": "グループ「{{name}}」を解散しました",
"deleteAgentTitle": "Agent を削除",
"deleteAgentDescription": "「{{name}}」を削除してもよろしいですか?Agent とそのワークスペースデータは完全に削除されます。",
"deleteFailed": "Agent の削除に失敗しました。もう一度お試しください。",
"deleteSuccess": "Agent \"{{name}}\" を削除しました",
"networkError": "ネットワークエラーです。接続を確認して再試行してください。",
"renameFailed": "名前の変更に失敗しました",
"renameSuccess": "「{{name}}」に名前を変更しました",
"renameTitle": "Agent の名前を変更",
"newName": "新しい名前",
"newNamePlaceholder": "新しい名前を入力",
"selectImageFile": "画像ファイルを選択してください",
"imageSizeLimit": "画像サイズは 2MB 未満にしてください",
"uploadFailed": "アップロードに失敗しました",
"avatarUpdated": "アバターを更新しました",
"changeAvatarTitle": "アバターを変更",
"preview": "プレビュー",
"chooseAvatar": "アバターを選択",
"refreshBatch": "更新",
"orUploadCustom": "またはカスタムアバターをアップロード",
"clickToUpload": "クリックして画像をアップロード",
"uploadSupport": "JPG、PNG、WebP に対応(最大 2MB)"
},
"groupMembers": {
"title": "メンバー ({{count}})",
"collaborationMode": "モード:",
"maxResponders": "各回で最大 {{count}} 人が応答します",
"admin": "管理者",
"addMembers": "+ メンバーを追加"
},
"virtualOffice": {
"boardTitle": "Q3 Goals",
"coffeeMachine": "Coffee Machine",
"statusBar": "{{working}} working · {{idle}} idle",
"viewTeamPanel": "View Team Panel >>"
},
"cronView": {
"title": "Scheduled Jobs",
"refresh": "Refresh",
"status": "Status:",
"enabled": "Enabled",
"disabled": "Disabled",
"jobCount": "Jobs:",
"nextWake": "Next wake:",
"jobList": "Job List",
"showCount": "Showing {{shown}} / Total {{total}}",
"loading": "Loading...",
"empty": "No scheduled jobs",
"expired": "expired"
},
"modelConfig": {
"loadFailed": "Failed to load model config: ",
"saved": "Model config saved",
"saveFailed": "Save failed: ",
"unknownError": "Unknown error",
"loading": "Loading...",
"activeConfig": "Active Configuration",
"primaryModel": "Primary model:",
"notSelected": "-- Not selected --",
"noApiKey": " (No API Key)",
"current": "Current:",
"searchPlaceholder": "Search models (filter by ID or name)",
"noMatch": "No matching providers or models",
"noProviders": "No providers yet, please add one",
"saving": "Saving...",
"saveConfig": "Save Config",
"modelCount": "{{count}} models",
"available": "Available {{ms}}ms",
"unavailable": "Unavailable",
"apiType": "API Type",
"note": "Note",
"notePlaceholder": "e.g. Company account / Personal test",
"modelList": "Model List",
"modelIdPlaceholder": "Model ID",
"customModel": "Custom Model",
"deleteConfirm": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone until you save.",
"editProvider": "Edit {{name}}",
"editProviderDesc": "Configure provider connection and manage model list",
"addModel": "Add Model",
"suggestedModels": "Suggested Models",
"done": "Done",
"reasoning": "Reasoning",
"vision": "ビジョン",
"providerId": "Provider ID",
"addProvider": "Add Provider",
"addProviderDesc": "Choose a known AI provider for quick setup, or create a custom one",
"searchProviderPlaceholder": "Search providers...",
"addCustomProvider": "Custom Provider",
"providerCategoryCn": "China",
"providerCategoryIntl": "International",
"providerCategoryAgg": "Aggregation",
"alreadyAdded": "Added"
},
"orchestration": {
"strategy": {
"skill-match": {
"label": "スマートマッチ",
"description": "質問内容に応じて最も適切なメンバーに自動で割り当てます"
},
"coordinator": {
"label": "コーディネーター",
"description": "1 人のコーディネーターがタスクを分析し、最適な担当者を割り当てます"
},
"round-robin": {
"label": "順番制",
"description": "メンバーが順番に各リクエストへ回答します"
},
"all": {
"label": "全員応答",
"description": "すべてのメンバーが各リクエストに回答します"
}
}
},
"changelog": {
"title": "更新履歴"
},
"onboarding": {
"title": "EasiestClaw AI へようこそ",
"subtitle": "AI チームがあなたを知るための簡単なセットアップ",
"avatarSectionTitle": "アバターを選択",
"nameSectionTitle": "お名前",
"namePlaceholder": "名前を入力してください",
"getStarted": "始める",
"skip": "今はスキップ",
"nameRequired": "先にお名前を入力してください",
"extracting": "OpenClaw を解凍中...",
"gatewayStarting": "OpenClaw を起動中...",
"gatewayStartingDesc": "初回起動には数秒かかる場合があります。ファイアウォールの確認が表示されたら「許可」をクリックしてください",
"gatewayConnecting": "Gateway に接続中...",
"gatewayReady": "Gateway 準備完了、読み込み中..."
}
}
-304
View File
@@ -1,304 +0,0 @@
{
"meta": {
"title": "EasiestClaw - AI 가상 회사",
"description": "비개발자도 채팅하듯 AI 팀을 지휘할 수 있게 해주는 OpenClaw용 친화적인 인터페이스입니다."
},
"common": {
"cancel": "취소",
"create": "생성",
"creating": "생성 중",
"save": "저장",
"saving": "저장 중",
"confirm": "확인",
"delete": "삭제",
"deleting": "삭제 중",
"justNow": "방금",
"me": "나",
"meHuman": "나 (인간)",
"memberCount": "{{count}}명",
"selectedCount": "{{count}}개 선택됨",
"searchPlaceholder": "검색..."
},
"nav": {
"chat": "메시지",
"virtualTeam": "가상 팀",
"cron": "예약 작업",
"settings": "설정"
},
"topNav": {
"connection": {
"connected": "OpenClaw 연결됨",
"connecting": "연결 중...",
"disconnected": "연결 안 됨",
"error": "연결 오류"
},
"comingSoon": "곧 지원"
},
"settings": {
"backToApp": "EasiestClaw로 돌아가기",
"sections": {
"models": "모델 설정",
"gateway": "게이트웨이",
"language": "언어",
"community": "커뮤니티"
},
"descriptions": {
"models": "AI 제공자를 추가하고 사용 가능한 모델을 설정합니다",
"gateway": "OpenClaw 게이트웨이 연결 주소와 액세스 토큰을 설정",
"language": "기본적으로 브라우저 언어를 따르며, 수동으로 변경할 수도 있습니다",
"community": "공식 WeChat 그룹에 참여하여 최신 소식과 지원을 받으세요"
},
"community": {
"title": "공식 커뮤니티",
"description": "아래 QR 코드를 스캔하여 EasiestClaw 공식 WeChat 그룹에 참여하세요",
"qrTip": "WeChat으로 스캔하여 참여"
},
"language": {
"current": "현재 인터페이스 언어",
"system": "브라우저 언어 따르기",
"systemDescription": "처음 방문할 때 브라우저의 기본 언어를 사용합니다"
}
},
"conversationList": {
"title": "메시지",
"pin": "고정",
"unpin": "고정 해제",
"dissolveGroup": "그룹 해산",
"addNewMember": "새 멤버 추가",
"createGroup": "그룹 만들기",
"noMessages": "아직 메시지가 없습니다"
},
"newConversation": {
"title": "대화를 시작할 팀원을 선택하세요",
"searchMembers": "팀원 검색...",
"recommended": "추천",
"allMembers": "전체 멤버",
"skillsPrefix": "전문 분야"
},
"createAgent": {
"gatewayUnavailable": "게이트웨이를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.",
"createFailed": "Agent 생성에 실패했습니다. 다시 시도해 주세요.",
"networkError": "네트워크 오류입니다. 연결을 확인한 후 다시 시도해 주세요.",
"success": "Agent \"{{name}}\" 생성 완료",
"title": "새 Agent 만들기",
"description": "가상 팀에 새로운 AI 팀원을 추가합니다",
"nameLabel": "Agent 이름 *",
"namePlaceholder": "Agent 이름 입력",
"descriptionLabel": "설명 (선택 사항)",
"descriptionPlaceholder": "이 Agent의 역할을 간단히 설명하세요. 예: 시장 동향과 경쟁사 데이터를 분석함",
"descriptionHelper": "설명이 있으면 팀이 Agent의 역할을 더 잘 이해할 수 있습니다"
},
"groupCreate": {
"createdMessage": "그룹이 생성되었습니다",
"title": "새 그룹 만들기",
"nameLabel": "그룹 이름 *",
"namePlaceholder": "제품 출시 팀",
"purposeLabel": "목적 (선택 사항)",
"purposePlaceholder": "Q2 제품 출시를 기획·조율합니다. 시장 분석, 카피라이팅, 데이터 지원, 일정 관리가 포함됩니다.",
"purposeHelper": "목적을 적어두면 협업 맥락을 더 잘 이해할 수 있습니다",
"strategyLabel": "협업 모드",
"maxResponders": "최대 응답 인원",
"coordinatorLabel": "코디네이터 선택",
"coordinatorRequired": "코디네이터를 선택해 주세요",
"selectMembers": "팀원 선택",
"searchMembers": "팀원 검색...",
"createWithCount": "그룹 만들기 ({{count}})"
},
"chatWindow": {
"today": "오늘",
"yesterday": "어제",
"emptyTitle": "대화를 선택해 채팅을 시작하세요",
"emptyDescription": "또는 \"새 대화\"를 클릭해 팀과 대화하세요",
"notConnectedTitle": "OpenClaw에 연결되지 않았습니다",
"notConnectedDesc": "연결하면 에이전트와 채팅할 수 있습니다",
"goToOpenclaw": "OpenClaw 상태 페이지로 이동",
"fullDate": "{{year}}-{{month}}-{{day}}"
},
"typingIndicator": {
"thinking": "{{name}}님이 생각 중..."
},
"messageBubble": {
"taskStatus": {
"completed": "완료됨",
"inProgress": "진행 중",
"failed": "실패"
},
"progress": "진행률",
"scope": "범위: {{scope}}",
"viewDetails": "자세히 보기",
"downloadReport": "보고서 다운로드",
"download": "다운로드",
"preview": "미리보기"
},
"messageInput": {
"placeholders": {
"withAttachments": "메시지를 추가하거나 이미지를 계속 붙여넣으세요...",
"default": "메시지 입력... Enter 전송, Shift+Enter 줄바꿈, 이미지 붙여넣기 지원"
},
"toolbar": {
"format": "서식",
"mention": "멘션",
"shortcut": "바로가기",
"attachment": "첨부",
"more": "더보기",
"newSession": "새 세션",
"expand": "확장",
"send": "전송"
}
},
"header": {
"status": {
"online": "온라인",
"working": "작업 중",
"busy": "바쁨",
"thinking": "생각 중"
},
"tooltips": {
"voiceCall": "음성 통화",
"videoCall": "영상 통화",
"searchMessages": "메시지 검색",
"membersList": "멤버 목록"
},
"menu": {
"viewProfile": "프로필 보기",
"rename": "이름 변경",
"changeAvatar": "아바타 변경",
"pinConversation": "대화 고정",
"muteNotifications": "알림 끄기",
"clearHistory": "기록 지우기",
"dissolveGroup": "그룹 해산",
"deleteAgent": "Agent 삭제"
},
"dissolveGroupConfirm": "정말로 \"{{name}}\" 그룹을 해산하시겠습니까? 모든 메시지 기록이 삭제됩니다.",
"dissolveGroupSuccess": "그룹 \"{{name}}\" 이(가) 해산되었습니다",
"deleteAgentTitle": "Agent 삭제",
"deleteAgentDescription": "정말로 \"{{name}}\" 을(를) 삭제하시겠습니까? Agent와 작업공간 데이터가 영구적으로 제거됩니다.",
"deleteFailed": "Agent 삭제에 실패했습니다. 다시 시도해 주세요.",
"deleteSuccess": "Agent \"{{name}}\" 이(가) 삭제되었습니다",
"networkError": "네트워크 오류입니다. 연결을 확인한 후 다시 시도해 주세요.",
"renameFailed": "이름 변경 실패",
"renameSuccess": "이름이 \"{{name}}\"(으)로 변경되었습니다",
"renameTitle": "Agent 이름 변경",
"newName": "새 이름",
"newNamePlaceholder": "새 이름 입력",
"selectImageFile": "이미지 파일을 선택해 주세요",
"imageSizeLimit": "이미지 크기는 2MB 이하여야 합니다",
"uploadFailed": "업로드 실패",
"avatarUpdated": "아바타가 업데이트되었습니다",
"changeAvatarTitle": "아바타 변경",
"preview": "미리보기",
"chooseAvatar": "아바타 선택",
"refreshBatch": "새로고침",
"orUploadCustom": "또는 사용자 지정 아바타 업로드",
"clickToUpload": "클릭하여 이미지 업로드",
"uploadSupport": "JPG, PNG, WebP 지원, 최대 2MB"
},
"groupMembers": {
"title": "멤버 ({{count}})",
"collaborationMode": "모드:",
"maxResponders": "매 라운드마다 최대 {{count}}명이 응답합니다",
"admin": "관리자",
"addMembers": "+ 멤버 추가"
},
"virtualOffice": {
"boardTitle": "Q3 Goals",
"coffeeMachine": "Coffee Machine",
"statusBar": "{{working}} working · {{idle}} idle",
"viewTeamPanel": "View Team Panel >>"
},
"cronView": {
"title": "Scheduled Jobs",
"refresh": "Refresh",
"status": "Status:",
"enabled": "Enabled",
"disabled": "Disabled",
"jobCount": "Jobs:",
"nextWake": "Next wake:",
"jobList": "Job List",
"showCount": "Showing {{shown}} / Total {{total}}",
"loading": "Loading...",
"empty": "No scheduled jobs",
"expired": "expired"
},
"modelConfig": {
"loadFailed": "Failed to load model config: ",
"saved": "Model config saved",
"saveFailed": "Save failed: ",
"unknownError": "Unknown error",
"loading": "Loading...",
"activeConfig": "Active Configuration",
"primaryModel": "Primary model:",
"notSelected": "-- Not selected --",
"noApiKey": " (No API Key)",
"current": "Current:",
"searchPlaceholder": "Search models (filter by ID or name)",
"noMatch": "No matching providers or models",
"noProviders": "No providers yet, please add one",
"saving": "Saving...",
"saveConfig": "Save Config",
"modelCount": "{{count}} models",
"available": "Available {{ms}}ms",
"unavailable": "Unavailable",
"apiType": "API Type",
"note": "Note",
"notePlaceholder": "e.g. Company account / Personal test",
"modelList": "Model List",
"modelIdPlaceholder": "Model ID",
"customModel": "Custom Model",
"deleteConfirm": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone until you save.",
"editProvider": "Edit {{name}}",
"editProviderDesc": "Configure provider connection and manage model list",
"addModel": "Add Model",
"suggestedModels": "Suggested Models",
"done": "Done",
"reasoning": "Reasoning",
"vision": "비전",
"providerId": "Provider ID",
"addProvider": "Add Provider",
"addProviderDesc": "Choose a known AI provider for quick setup, or create a custom one",
"searchProviderPlaceholder": "Search providers...",
"addCustomProvider": "Custom Provider",
"providerCategoryCn": "China",
"providerCategoryIntl": "International",
"providerCategoryAgg": "Aggregation",
"alreadyAdded": "Added"
},
"orchestration": {
"strategy": {
"skill-match": {
"label": "스마트 매치",
"description": "질문 내용에 따라 가장 적합한 멤버에게 자동으로 라우팅합니다"
},
"coordinator": {
"label": "코디네이터",
"description": "한 명의 코디네이터가 작업을 분석하고 최적의 담당자를 배정합니다"
},
"round-robin": {
"label": "라운드 로빈",
"description": "멤버가 번갈아 가며 각 요청에 응답합니다"
},
"all": {
"label": "모두 응답",
"description": "모든 멤버가 각 요청에 응답합니다"
}
}
},
"changelog": {
"title": "변경 이력"
},
"onboarding": {
"title": "EasiestClaw AI에 오신 것을 환영합니다",
"subtitle": "AI 팀이 당신을 알 수 있도록 간단히 설정하세요",
"avatarSectionTitle": "아바타 선택",
"nameSectionTitle": "이름",
"namePlaceholder": "이름을 입력하세요",
"getStarted": "시작하기",
"skip": "지금은 건너뛰기",
"nameRequired": "먼저 이름을 입력해 주세요",
"extracting": "OpenClaw 압축 해제 중...",
"gatewayStarting": "OpenClaw를 시작하는 중...",
"gatewayStartingDesc": "첫 실행은 몇 초가 걸릴 수 있습니다. 방화벽 메시지가 나타나면 \"허용\"을 클릭하세요",
"gatewayConnecting": "Gateway에 연결하는 중...",
"gatewayReady": "Gateway 준비 완료, 로딩 중..."
}
}
-304
View File
@@ -1,304 +0,0 @@
{
"meta": {
"title": "EasiestClaw - виртуальная AI-компания",
"description": "Дружелюбная оболочка для OpenClaw, которая позволяет управлять AI-командой так же естественно, как в чате."
},
"common": {
"cancel": "Отмена",
"create": "Создать",
"creating": "Создание",
"save": "Сохранить",
"saving": "Сохранение",
"confirm": "Подтвердить",
"delete": "Удалить",
"deleting": "Удаление",
"justNow": "Только что",
"me": "Я",
"meHuman": "Я (человек)",
"memberCount": "{{count}} участников",
"selectedCount": "Выбрано: {{count}}",
"searchPlaceholder": "Поиск..."
},
"nav": {
"chat": "Сообщения",
"virtualTeam": "Виртуальная команда",
"cron": "Запланированные задачи",
"settings": "Настройки"
},
"topNav": {
"connection": {
"connected": "Подключено к OpenClaw",
"connecting": "Подключение...",
"disconnected": "Не подключено",
"error": "Ошибка подключения"
},
"comingSoon": "Скоро будет"
},
"settings": {
"backToApp": "Вернуться в EasiestClaw",
"sections": {
"models": "Настройка моделей",
"gateway": "Шлюз",
"language": "Язык",
"community": "Сообщество"
},
"descriptions": {
"models": "Добавьте AI-провайдеров и настройте доступные модели",
"gateway": "Настройка адреса подключения и токена доступа шлюза OpenClaw",
"language": "По умолчанию используется язык браузера, но его можно сменить вручную",
"community": "Присоединяйтесь к официальной группе WeChat для получения обновлений и поддержки"
},
"community": {
"title": "Официальное сообщество",
"description": "Отсканируйте QR-код ниже, чтобы присоединиться к официальной группе EasiestClaw в WeChat",
"qrTip": "Отсканируйте в WeChat для вступления"
},
"language": {
"current": "Текущий язык интерфейса",
"system": "Следовать языку браузера",
"systemDescription": "При первом открытии используется предпочтительный язык браузера"
}
},
"conversationList": {
"title": "Сообщения",
"pin": "Закрепить",
"unpin": "Открепить",
"dissolveGroup": "Распустить группу",
"addNewMember": "Добавить участника",
"createGroup": "Создать группу",
"noMessages": "Сообщений пока нет"
},
"newConversation": {
"title": "Выберите участника для начала чата",
"searchMembers": "Поиск участников...",
"recommended": "Рекомендуемые",
"allMembers": "Все участники",
"skillsPrefix": "Сильные стороны"
},
"createAgent": {
"gatewayUnavailable": "Шлюз недоступен. Повторите попытку позже.",
"createFailed": "Не удалось создать Agent. Повторите попытку.",
"networkError": "Ошибка сети. Проверьте соединение и повторите попытку.",
"success": "Agent \"{{name}}\" успешно создан",
"title": "Создать нового Agent",
"description": "Добавьте нового AI-помощника в виртуальную команду",
"nameLabel": "Имя Agent *",
"namePlaceholder": "Введите имя Agent",
"descriptionLabel": "Описание (необязательно)",
"descriptionPlaceholder": "Кратко опишите роль этого Agent, например: анализирует рыночные тренды и данные о конкурентах",
"descriptionHelper": "Описание помогает команде лучше понять роль Agent"
},
"groupCreate": {
"createdMessage": "Группа создана",
"title": "Создать новую группу",
"nameLabel": "Название группы *",
"namePlaceholder": "Команда запуска продукта",
"purposeLabel": "Цель (необязательно)",
"purposePlaceholder": "Планирование и координация запуска продукта во 2 квартале: анализ рынка, тексты, поддержка данными и график",
"purposeHelper": "Описание цели помогает участникам понять контекст сотрудничества",
"strategyLabel": "Режим взаимодействия",
"maxResponders": "Макс. число ответов",
"coordinatorLabel": "Выберите координатора",
"coordinatorRequired": "Пожалуйста, выберите координатора",
"selectMembers": "Выберите участников",
"searchMembers": "Поиск участников...",
"createWithCount": "Создать группу ({{count}})"
},
"chatWindow": {
"today": "Сегодня",
"yesterday": "Вчера",
"emptyTitle": "Выберите диалог, чтобы начать общение",
"emptyDescription": "Или нажмите «Новый диалог», чтобы поговорить с командой",
"notConnectedTitle": "Нет подключения к OpenClaw",
"notConnectedDesc": "Подключитесь, чтобы начать чат с агентами",
"goToOpenclaw": "Перейти на страницу состояния OpenClaw",
"fullDate": "{{day}}.{{month}}.{{year}}"
},
"typingIndicator": {
"thinking": "{{name}} думает..."
},
"messageBubble": {
"taskStatus": {
"completed": "Завершено",
"inProgress": "В процессе",
"failed": "Ошибка"
},
"progress": "Прогресс",
"scope": "Область: {{scope}}",
"viewDetails": "Подробнее",
"downloadReport": "Скачать отчёт",
"download": "Скачать",
"preview": "Предпросмотр"
},
"messageInput": {
"placeholders": {
"withAttachments": "Добавьте сообщение или продолжайте вставлять изображения...",
"default": "Введите сообщение... Enter — отправить, Shift+Enter — новая строка, поддерживается вставка изображений"
},
"toolbar": {
"format": "Формат",
"mention": "Упоминание",
"shortcut": "Команда",
"attachment": "Вложение",
"more": "Ещё",
"newSession": "Новая сессия",
"expand": "Развернуть",
"send": "Отправить"
}
},
"header": {
"status": {
"online": "В сети",
"working": "Работает",
"busy": "Занят",
"thinking": "Думает"
},
"tooltips": {
"voiceCall": "Голосовой звонок",
"videoCall": "Видеозвонок",
"searchMessages": "Поиск сообщений",
"membersList": "Список участников"
},
"menu": {
"viewProfile": "Открыть профиль",
"rename": "Переименовать",
"changeAvatar": "Сменить аватар",
"pinConversation": "Закрепить диалог",
"muteNotifications": "Отключить уведомления",
"clearHistory": "Очистить историю",
"dissolveGroup": "Распустить группу",
"deleteAgent": "Удалить Agent"
},
"dissolveGroupConfirm": "Вы уверены, что хотите распустить «{{name}}»? Вся история сообщений будет удалена.",
"dissolveGroupSuccess": "Группа «{{name}}» распущена",
"deleteAgentTitle": "Удалить Agent",
"deleteAgentDescription": "Вы уверены, что хотите удалить «{{name}}»? Agent и его рабочее пространство будут удалены безвозвратно.",
"deleteFailed": "Не удалось удалить Agent. Повторите попытку.",
"deleteSuccess": "Agent \"{{name}}\" удалён",
"networkError": "Ошибка сети. Проверьте соединение и повторите попытку.",
"renameFailed": "Не удалось переименовать",
"renameSuccess": "Имя изменено на «{{name}}»",
"renameTitle": "Переименовать Agent",
"newName": "Новое имя",
"newNamePlaceholder": "Введите новое имя",
"selectImageFile": "Выберите файл изображения",
"imageSizeLimit": "Размер изображения должен быть меньше 2 МБ",
"uploadFailed": "Ошибка загрузки",
"avatarUpdated": "Аватар обновлён",
"changeAvatarTitle": "Сменить аватар",
"preview": "Предпросмотр",
"chooseAvatar": "Выбрать аватар",
"refreshBatch": "Обновить",
"orUploadCustom": "Или загрузите свой аватар",
"clickToUpload": "Нажмите, чтобы загрузить изображение",
"uploadSupport": "Поддерживаются JPG, PNG, WebP до 2 МБ"
},
"groupMembers": {
"title": "Участники ({{count}})",
"collaborationMode": "Режим:",
"maxResponders": "До {{count}} участников отвечают в каждом раунде",
"admin": "Администратор",
"addMembers": "+ Добавить участников"
},
"virtualOffice": {
"boardTitle": "Q3 Goals",
"coffeeMachine": "Coffee Machine",
"statusBar": "{{working}} working · {{idle}} idle",
"viewTeamPanel": "View Team Panel >>"
},
"cronView": {
"title": "Scheduled Jobs",
"refresh": "Refresh",
"status": "Status:",
"enabled": "Enabled",
"disabled": "Disabled",
"jobCount": "Jobs:",
"nextWake": "Next wake:",
"jobList": "Job List",
"showCount": "Showing {{shown}} / Total {{total}}",
"loading": "Loading...",
"empty": "No scheduled jobs",
"expired": "expired"
},
"modelConfig": {
"loadFailed": "Failed to load model config: ",
"saved": "Model config saved",
"saveFailed": "Save failed: ",
"unknownError": "Unknown error",
"loading": "Loading...",
"activeConfig": "Active Configuration",
"primaryModel": "Primary model:",
"notSelected": "-- Not selected --",
"noApiKey": " (No API Key)",
"current": "Current:",
"searchPlaceholder": "Search models (filter by ID or name)",
"noMatch": "No matching providers or models",
"noProviders": "No providers yet, please add one",
"saving": "Saving...",
"saveConfig": "Save Config",
"modelCount": "{{count}} models",
"available": "Available {{ms}}ms",
"unavailable": "Unavailable",
"apiType": "API Type",
"note": "Note",
"notePlaceholder": "e.g. Company account / Personal test",
"modelList": "Model List",
"modelIdPlaceholder": "Model ID",
"customModel": "Custom Model",
"deleteConfirm": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone until you save.",
"editProvider": "Edit {{name}}",
"editProviderDesc": "Configure provider connection and manage model list",
"addModel": "Add Model",
"suggestedModels": "Suggested Models",
"done": "Done",
"reasoning": "Reasoning",
"vision": "Зрение",
"providerId": "Provider ID",
"addProvider": "Add Provider",
"addProviderDesc": "Choose a known AI provider for quick setup, or create a custom one",
"searchProviderPlaceholder": "Search providers...",
"addCustomProvider": "Custom Provider",
"providerCategoryCn": "China",
"providerCategoryIntl": "International",
"providerCategoryAgg": "Aggregation",
"alreadyAdded": "Added"
},
"orchestration": {
"strategy": {
"skill-match": {
"label": "Умный подбор",
"description": "Автоматически направляет вопрос самым подходящим участникам"
},
"coordinator": {
"label": "Координатор",
"description": "Один координатор анализирует задачу и назначает лучших исполнителей"
},
"round-robin": {
"label": "По очереди",
"description": "Участники по очереди отвечают на каждый запрос"
},
"all": {
"label": "Отвечают все",
"description": "Каждый участник отвечает на каждый запрос"
}
}
},
"changelog": {
"title": "История изменений"
},
"onboarding": {
"title": "Добро пожаловать в EasiestClaw AI",
"subtitle": "Быстрая настройка, чтобы ваша AI-команда вас знала",
"avatarSectionTitle": "Выберите аватар",
"nameSectionTitle": "Ваше имя",
"namePlaceholder": "Введите ваше имя",
"getStarted": "Начать",
"skip": "Пропустить сейчас",
"nameRequired": "Пожалуйста, сначала введите ваше имя",
"extracting": "Распаковка OpenClaw...",
"gatewayStarting": "Запуск OpenClaw...",
"gatewayStartingDesc": "Первый запуск может занять несколько секунд. Если появится запрос брандмауэра, нажмите «Разрешить»",
"gatewayConnecting": "Подключение к Gateway...",
"gatewayReady": "Gateway готов, загрузка..."
}
}
-304
View File
@@ -1,304 +0,0 @@
{
"meta": {
"title": "EasiestClaw - công ty AI ảo",
"description": "Giao diện thân thiện cho OpenClaw, giúp người không biết lập trình cũng có thể điều phối đội AI như đang trò chuyện."
},
"common": {
"cancel": "Hủy",
"create": "Tạo",
"creating": "Đang tạo",
"save": "Lưu",
"saving": "Đang lưu",
"confirm": "Xác nhận",
"delete": "Xóa",
"deleting": "Đang xóa",
"justNow": "Vừa xong",
"me": "Tôi",
"meHuman": "Tôi (người)",
"memberCount": "{{count}} thành viên",
"selectedCount": "Đã chọn {{count}}",
"searchPlaceholder": "Tìm kiếm..."
},
"nav": {
"chat": "Tin nhắn",
"virtualTeam": "Đội ngũ ảo",
"cron": "Tác vụ định kỳ",
"settings": "Cài đặt"
},
"topNav": {
"connection": {
"connected": "Đã kết nối OpenClaw",
"connecting": "Đang kết nối...",
"disconnected": "Chưa kết nối",
"error": "Lỗi kết nối"
},
"comingSoon": "Sắp hỗ trợ"
},
"settings": {
"backToApp": "Quay lại EasiestClaw",
"sections": {
"models": "Cấu hình mô hình",
"gateway": "Cổng kết nối",
"language": "Ngôn ngữ",
"community": "Cộng đồng"
},
"descriptions": {
"models": "Thêm nhà cung cấp AI và cấu hình các mô hình khả dụng",
"gateway": "Cấu hình URL kết nối và mã truy cập cổng OpenClaw",
"language": "Mặc định theo ngôn ngữ trình duyệt, và bạn cũng có thể chuyển thủ công",
"community": "Tham gia nhóm WeChat chính thức để nhận cập nhật và hỗ trợ"
},
"community": {
"title": "Cộng đồng chính thức",
"description": "Quét mã QR bên dưới để tham gia nhóm WeChat chính thức của EasiestClaw",
"qrTip": "Quét bằng WeChat để tham gia"
},
"language": {
"current": "Ngôn ngữ giao diện hiện tại",
"system": "Theo trình duyệt",
"systemDescription": "Lần truy cập đầu tiên sẽ dùng ngôn ngữ ưu tiên của trình duyệt"
}
},
"conversationList": {
"title": "Tin nhắn",
"pin": "Ghim",
"unpin": "Bỏ ghim",
"dissolveGroup": "Giải tán nhóm",
"addNewMember": "Thêm thành viên mới",
"createGroup": "Tạo nhóm",
"noMessages": "Chưa có tin nhắn"
},
"newConversation": {
"title": "Chọn một đồng đội để bắt đầu trò chuyện",
"searchMembers": "Tìm thành viên...",
"recommended": "Gợi ý",
"allMembers": "Tất cả thành viên",
"skillsPrefix": "Chuyên môn"
},
"createAgent": {
"gatewayUnavailable": "Gateway hiện không khả dụng. Vui lòng thử lại sau.",
"createFailed": "Không thể tạo Agent. Vui lòng thử lại.",
"networkError": "Lỗi mạng. Hãy kiểm tra kết nối rồi thử lại.",
"success": "Đã tạo Agent \"{{name}}\" thành công",
"title": "Tạo Agent mới",
"description": "Thêm một trợ lý AI mới vào đội ngũ ảo của bạn",
"nameLabel": "Tên Agent *",
"namePlaceholder": "Nhập tên Agent",
"descriptionLabel": "Mô tả (tùy chọn)",
"descriptionPlaceholder": "Mô tả ngắn vai trò của Agent này, ví dụ: phân tích xu hướng thị trường và dữ liệu đối thủ",
"descriptionHelper": "Mô tả rõ ràng giúp cả đội hiểu vai trò của Agent tốt hơn"
},
"groupCreate": {
"createdMessage": "Đã tạo nhóm",
"title": "Tạo nhóm mới",
"nameLabel": "Tên nhóm *",
"namePlaceholder": "Nhóm ra mắt sản phẩm",
"purposeLabel": "Mục đích (tùy chọn)",
"purposePlaceholder": "Lập kế hoạch và điều phối ra mắt sản phẩm Q2, bao gồm phân tích thị trường, nội dung, hỗ trợ dữ liệu và lịch trình",
"purposeHelper": "Mục đích sẽ giúp các thành viên hiểu bối cảnh cộng tác tốt hơn",
"strategyLabel": "Chế độ cộng tác",
"maxResponders": "Số người phản hồi tối đa",
"coordinatorLabel": "Chọn điều phối viên",
"coordinatorRequired": "Vui lòng chọn một điều phối viên",
"selectMembers": "Chọn thành viên",
"searchMembers": "Tìm thành viên...",
"createWithCount": "Tạo nhóm ({{count}})"
},
"chatWindow": {
"today": "Hôm nay",
"yesterday": "Hôm qua",
"emptyTitle": "Chọn một cuộc trò chuyện để bắt đầu",
"emptyDescription": "Hoặc nhấn \"Cuộc trò chuyện mới\" để nói chuyện với đội của bạn",
"notConnectedTitle": "Chưa kết nối với OpenClaw",
"notConnectedDesc": "Kết nối để bắt đầu trò chuyện với các agent",
"goToOpenclaw": "Đi đến trang trạng thái OpenClaw",
"fullDate": "{{day}}/{{month}}/{{year}}"
},
"typingIndicator": {
"thinking": "{{name}} đang suy nghĩ..."
},
"messageBubble": {
"taskStatus": {
"completed": "Hoàn thành",
"inProgress": "Đang thực hiện",
"failed": "Thất bại"
},
"progress": "Tiến độ",
"scope": "Phạm vi: {{scope}}",
"viewDetails": "Xem chi tiết",
"downloadReport": "Tải báo cáo",
"download": "Tải xuống",
"preview": "Xem trước"
},
"messageInput": {
"placeholders": {
"withAttachments": "Thêm tin nhắn hoặc tiếp tục dán ảnh...",
"default": "Nhập tin nhắn... Enter để gửi, Shift+Enter để xuống dòng, hỗ trợ dán ảnh"
},
"toolbar": {
"format": "Định dạng",
"mention": "Nhắc tên",
"shortcut": "Phím tắt",
"attachment": "Tệp đính kèm",
"more": "Thêm",
"newSession": "Phiên mới",
"expand": "Mở rộng",
"send": "Gửi"
}
},
"header": {
"status": {
"online": "Trực tuyến",
"working": "Đang làm việc",
"busy": "Bận",
"thinking": "Đang suy nghĩ"
},
"tooltips": {
"voiceCall": "Gọi thoại",
"videoCall": "Gọi video",
"searchMessages": "Tìm trong tin nhắn",
"membersList": "Danh sách thành viên"
},
"menu": {
"viewProfile": "Xem hồ sơ",
"rename": "Đổi tên",
"changeAvatar": "Đổi avatar",
"pinConversation": "Ghim cuộc trò chuyện",
"muteNotifications": "Tắt thông báo",
"clearHistory": "Xóa lịch sử",
"dissolveGroup": "Giải tán nhóm",
"deleteAgent": "Xóa Agent"
},
"dissolveGroupConfirm": "Bạn có chắc muốn giải tán \"{{name}}\" không? Toàn bộ lịch sử tin nhắn sẽ bị xóa.",
"dissolveGroupSuccess": "Nhóm \"{{name}}\" đã được giải tán",
"deleteAgentTitle": "Xóa Agent",
"deleteAgentDescription": "Bạn có chắc muốn xóa \"{{name}}\" không? Agent và dữ liệu workspace của nó sẽ bị xóa vĩnh viễn.",
"deleteFailed": "Không thể xóa Agent. Vui lòng thử lại.",
"deleteSuccess": "Đã xóa Agent \"{{name}}\"",
"networkError": "Lỗi mạng. Hãy kiểm tra kết nối rồi thử lại.",
"renameFailed": "Đổi tên thất bại",
"renameSuccess": "Đã đổi tên thành \"{{name}}\"",
"renameTitle": "Đổi tên Agent",
"newName": "Tên mới",
"newNamePlaceholder": "Nhập tên mới",
"selectImageFile": "Vui lòng chọn một tệp ảnh",
"imageSizeLimit": "Kích thước ảnh phải nhỏ hơn 2MB",
"uploadFailed": "Tải lên thất bại",
"avatarUpdated": "Đã cập nhật avatar",
"changeAvatarTitle": "Đổi avatar",
"preview": "Xem trước",
"chooseAvatar": "Chọn avatar",
"refreshBatch": "Làm mới",
"orUploadCustom": "Hoặc tải lên avatar tùy chỉnh",
"clickToUpload": "Nhấn để tải ảnh lên",
"uploadSupport": "Hỗ trợ JPG, PNG, WebP tối đa 2MB"
},
"groupMembers": {
"title": "Thành viên ({{count}})",
"collaborationMode": "Chế độ:",
"maxResponders": "Tối đa {{count}} thành viên phản hồi ở mỗi vòng",
"admin": "Quản trị viên",
"addMembers": "+ Thêm thành viên"
},
"virtualOffice": {
"boardTitle": "Q3 Goals",
"coffeeMachine": "Coffee Machine",
"statusBar": "{{working}} working · {{idle}} idle",
"viewTeamPanel": "View Team Panel >>"
},
"cronView": {
"title": "Scheduled Jobs",
"refresh": "Refresh",
"status": "Status:",
"enabled": "Enabled",
"disabled": "Disabled",
"jobCount": "Jobs:",
"nextWake": "Next wake:",
"jobList": "Job List",
"showCount": "Showing {{shown}} / Total {{total}}",
"loading": "Loading...",
"empty": "No scheduled jobs",
"expired": "expired"
},
"modelConfig": {
"loadFailed": "Failed to load model config: ",
"saved": "Model config saved",
"saveFailed": "Save failed: ",
"unknownError": "Unknown error",
"loading": "Loading...",
"activeConfig": "Active Configuration",
"primaryModel": "Primary model:",
"notSelected": "-- Not selected --",
"noApiKey": " (No API Key)",
"current": "Current:",
"searchPlaceholder": "Search models (filter by ID or name)",
"noMatch": "No matching providers or models",
"noProviders": "No providers yet, please add one",
"saving": "Saving...",
"saveConfig": "Save Config",
"modelCount": "{{count}} models",
"available": "Available {{ms}}ms",
"unavailable": "Unavailable",
"apiType": "API Type",
"note": "Note",
"notePlaceholder": "e.g. Company account / Personal test",
"modelList": "Model List",
"modelIdPlaceholder": "Model ID",
"customModel": "Custom Model",
"deleteConfirm": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone until you save.",
"editProvider": "Edit {{name}}",
"editProviderDesc": "Configure provider connection and manage model list",
"addModel": "Add Model",
"suggestedModels": "Suggested Models",
"done": "Done",
"reasoning": "Reasoning",
"vision": "Nhìn",
"providerId": "Provider ID",
"addProvider": "Add Provider",
"addProviderDesc": "Choose a known AI provider for quick setup, or create a custom one",
"searchProviderPlaceholder": "Search providers...",
"addCustomProvider": "Custom Provider",
"providerCategoryCn": "China",
"providerCategoryIntl": "International",
"providerCategoryAgg": "Aggregation",
"alreadyAdded": "Added"
},
"orchestration": {
"strategy": {
"skill-match": {
"label": "Ghép thông minh",
"description": "Tự động chuyển câu hỏi đến những thành viên phù hợp nhất"
},
"coordinator": {
"label": "Điều phối viên",
"description": "Một điều phối viên sẽ phân tích nhiệm vụ và chỉ định người phù hợp nhất"
},
"round-robin": {
"label": "Luân phiên",
"description": "Các thành viên lần lượt phản hồi từng yêu cầu"
},
"all": {
"label": "Tất cả phản hồi",
"description": "Mọi thành viên đều phản hồi cho từng yêu cầu"
}
}
},
"changelog": {
"title": "Nhật ký thay đổi"
},
"onboarding": {
"title": "Chào mừng đến với EasiestClaw AI",
"subtitle": "Thiết lập nhanh để đội AI của bạn biết bạn là ai",
"avatarSectionTitle": "Chọn avatar",
"nameSectionTitle": "Tên của bạn",
"namePlaceholder": "Nhập tên của bạn",
"getStarted": "Bắt đầu",
"skip": "Bỏ qua lúc này",
"nameRequired": "Vui lòng nhập tên của bạn trước",
"extracting": "Đang giải nén OpenClaw...",
"gatewayStarting": "Đang khởi động OpenClaw...",
"gatewayStartingDesc": "Lần đầu khởi động có thể mất vài giây. Nếu có thông báo tường lửa, hãy nhấp vào \"Cho phép\"",
"gatewayConnecting": "Đang kết nối với Gateway...",
"gatewayReady": "Gateway sẵn sàng, đang tải..."
}
}
+47 -3
View File
@@ -88,7 +88,8 @@
"createGroupComingSoon": "即将支持,敬请期待",
"noMessages": "暂无消息",
"refreshSuccess": "已刷新",
"refreshFailed": "刷新失败,请重试"
"refreshFailed": "刷新失败,请重试",
"loadingAgents": "正在加载团队成员..."
},
"newConversation": {
"title": "选择一位团队成员开始对话",
@@ -364,7 +365,12 @@
"dataLocationChooseHint": "点击浏览,选择一个文件夹",
"dataLocationDefault": "使用默认位置",
"dataLocationDefaultHint": "数据将存储在系统应用数据目录",
"dataLocationConfirm": "确认并继续"
"dataLocationConfirm": "确认并继续",
"modelCheckTitle": "配置 AI 模型",
"modelCheckDesc": "未检测到模型配置,您需要至少配置一个 AI 模型才能正常使用。",
"modelCheckConfigure": "去配置",
"modelCheckSkip": "稍后配置",
"modelCheckSkipHint": "您可以随时在设置中配置模型"
},
"windowControls": {
"minimize": "最小化",
@@ -384,7 +390,11 @@
"readonly": "只读",
"selectFile": "选择文件查看内容",
"emptyFile": "文件为空,输入内容后点击保存",
"emptyMemory": "暂无内容"
"emptyMemory": "暂无内容",
"refresh": "刷新",
"binaryFile": "二进制文件,无法预览",
"fileTooLarge": "文件过大,无法预览",
"openExternal": "用默认应用打开"
},
"channels": {
"title": "渠道管理",
@@ -432,5 +442,39 @@
"streamingBlock": "分块",
"streamingProgress": "进度条"
}
},
"skills": {
"title": "Skills",
"subtitle": "全局技能库管理",
"tabInstalled": "已安装",
"tabMarketplace": "技能市场",
"installed": "已安装技能",
"refresh": "刷新",
"loading": "加载中...",
"noSkills": "暂无可用技能",
"retry": "重试",
"enable": "点击启用",
"disable": "点击禁用",
"searchPlaceholder": "搜索技能...",
"searching": "搜索中...",
"noResults": "没有找到匹配的技能",
"tryOther": "试试其他关键词",
"install": "安装",
"installing": "安装中...",
"installed_badge": "已安装",
"installSuccess": "技能安装成功",
"installFailed": "安装失败",
"version": "v{{version}}",
"score": "匹配度",
"by": "by",
"downloads": "下载",
"explore": "热门推荐",
"exploreHint": "输入关键词搜索 ClawHub 技能市场",
"marketplaceError": "获取市场数据失败",
"openClawHub": "浏览 ClawHub",
"changelog": "更新日志",
"viewOnClawHub": "在 ClawHub 查看",
"malwareBlocked": "此技能因恶意软件被封禁",
"suspicious": "此技能已被标记为可疑"
}
}
-304
View File
@@ -1,304 +0,0 @@
{
"meta": {
"title": "EasiestClaw - AI虛擬公司",
"description": "OpenClaw 的人性化互動外殼,讓非程式設計師像用飛書聊天一樣指揮 AI 團隊工作。"
},
"common": {
"cancel": "取消",
"create": "建立",
"creating": "建立中",
"save": "儲存",
"saving": "儲存中",
"confirm": "確認",
"delete": "刪除",
"deleting": "刪除中",
"justNow": "剛剛",
"me": "我",
"meHuman": "我(人類)",
"memberCount": "{{count}} 位成員",
"selectedCount": "已選 {{count}} 位",
"searchPlaceholder": "搜尋..."
},
"nav": {
"chat": "訊息",
"virtualTeam": "虛擬團隊",
"cron": "定時任務",
"settings": "設定"
},
"topNav": {
"connection": {
"connected": "已連線至 OpenClaw",
"connecting": "正在連線...",
"disconnected": "未連線",
"error": "連線錯誤"
},
"comingSoon": "即將支援"
},
"settings": {
"backToApp": "返回",
"sections": {
"models": "模型設定",
"gateway": "閘道器",
"language": "語言",
"community": "社群"
},
"descriptions": {
"models": "添加 AI 模型服務商,配置可用模型",
"gateway": "設定 OpenClaw 閘道器連線位址和存取權杖",
"language": "預設跟隨瀏覽器語言,也可以手動切換",
"community": "加入官方微信群,獲取最新動態和技術支援"
},
"community": {
"title": "官方社群",
"description": "掃描下方二維碼加入 EasiestClaw 官方微信群,與其他用戶交流使用心得",
"qrTip": "微信掃一掃,加入官方群"
},
"language": {
"current": "當前介面語言",
"system": "跟隨瀏覽器",
"systemDescription": "首次開啟時自動採用瀏覽器預設語言"
}
},
"conversationList": {
"title": "訊息",
"pin": "置頂",
"unpin": "取消置頂",
"dissolveGroup": "解散群組",
"addNewMember": "新增成員",
"createGroup": "建立群組",
"noMessages": "暫無訊息"
},
"newConversation": {
"title": "選擇一位團隊成員開始對話",
"searchMembers": "搜尋團隊成員...",
"recommended": "推薦",
"allMembers": "全部成員",
"skillsPrefix": "擅長"
},
"createAgent": {
"gatewayUnavailable": "網關服務不可用,請稍後再試",
"createFailed": "建立失敗,請重試",
"networkError": "網路錯誤,請檢查連線後重試",
"success": "Agent \"{{name}}\" 建立成功",
"title": "建立新 Agent",
"description": "為你的虛擬團隊添加一位新的 AI 助手",
"nameLabel": "Agent 名稱 *",
"namePlaceholder": "輸入 Agent 名稱",
"descriptionLabel": "描述(選填)",
"descriptionPlaceholder": "簡要描述這個 Agent 的職責,例如:負責分析市場趨勢和競品數據",
"descriptionHelper": "描述會幫助團隊成員更好理解 Agent 的角色"
},
"groupCreate": {
"createdMessage": "群組已建立",
"title": "建立新群組",
"nameLabel": "群組名稱 *",
"namePlaceholder": "產品發布小組",
"purposeLabel": "群組用途(選填)",
"purposePlaceholder": "策劃和協調 Q2 產品發布,包括市場分析、文案、數據支持和排期",
"purposeHelper": "用途描述會幫助團隊成員更好理解協作脈絡",
"strategyLabel": "協作模式",
"maxResponders": "最多回應人數",
"coordinatorLabel": "選擇協調人",
"coordinatorRequired": "請選擇一位協調人",
"selectMembers": "選擇團隊成員",
"searchMembers": "搜尋團隊成員...",
"createWithCount": "建立群組 ({{count}})"
},
"chatWindow": {
"today": "今天",
"yesterday": "昨天",
"emptyTitle": "選擇一個對話開始聊天",
"emptyDescription": "或點擊「新建對話」與團隊成員交流",
"notConnectedTitle": "未連接到 OpenClaw",
"notConnectedDesc": "連接後才能與 Agent 對話",
"goToOpenclaw": "前往 OpenClaw 狀態頁",
"fullDate": "{{year}}年{{month}}月{{day}}日"
},
"typingIndicator": {
"thinking": "{{name}} 正在思考..."
},
"messageBubble": {
"taskStatus": {
"completed": "已完成",
"inProgress": "進行中",
"failed": "失敗"
},
"progress": "進度",
"scope": "範圍:{{scope}}",
"viewDetails": "查看詳情",
"downloadReport": "下載報告",
"download": "下載",
"preview": "預覽"
},
"messageInput": {
"placeholders": {
"withAttachments": "新增訊息或繼續貼上圖片...",
"default": "輸入訊息... Enter 發送,Shift+Enter 換行,可貼上圖片"
},
"toolbar": {
"format": "格式",
"mention": "提及",
"shortcut": "快捷指令",
"attachment": "附件",
"more": "更多",
"newSession": "新會話",
"expand": "展開",
"send": "發送"
}
},
"header": {
"status": {
"online": "在線",
"working": "工作中",
"busy": "忙碌",
"thinking": "思考中"
},
"tooltips": {
"voiceCall": "語音通話",
"videoCall": "視訊通話",
"searchMessages": "搜尋聊天記錄",
"membersList": "成員列表"
},
"menu": {
"viewProfile": "查看資料",
"rename": "重命名",
"changeAvatar": "更換頭像",
"pinConversation": "置頂對話",
"muteNotifications": "訊息免打擾",
"clearHistory": "清空記錄",
"dissolveGroup": "解散群組",
"deleteAgent": "刪除 Agent"
},
"dissolveGroupConfirm": "確定要解散群組「{{name}}」嗎?所有訊息記錄將被清除。",
"dissolveGroupSuccess": "群組「{{name}}」已解散",
"deleteAgentTitle": "確認刪除 Agent",
"deleteAgentDescription": "確定要刪除「{{name}}」嗎?此操作將移除該 Agent 及其工作區資料,且無法撤銷。",
"deleteFailed": "刪除失敗,請重試",
"deleteSuccess": "Agent \"{{name}}\" 已刪除",
"networkError": "網路錯誤,請檢查連線後重試",
"renameFailed": "重命名失敗",
"renameSuccess": "已重命名為「{{name}}」",
"renameTitle": "重命名 Agent",
"newName": "新名稱",
"newNamePlaceholder": "輸入新名稱",
"selectImageFile": "請選擇圖片檔案",
"imageSizeLimit": "圖片大小不能超過 2MB",
"uploadFailed": "上傳失敗",
"avatarUpdated": "頭像已更新",
"changeAvatarTitle": "更換頭像",
"preview": "預覽",
"chooseAvatar": "選擇頭像",
"refreshBatch": "換一批",
"orUploadCustom": "或上傳自訂頭像",
"clickToUpload": "點擊上傳圖片",
"uploadSupport": "支援 JPG、PNG、WebP,最大 2MB"
},
"groupMembers": {
"title": "成員 ({{count}})",
"collaborationMode": "協作模式:",
"maxResponders": "每次最多 {{count}} 人回應",
"admin": "管理員",
"addMembers": "+ 新增成員"
},
"virtualOffice": {
"boardTitle": "Q3 Goals",
"coffeeMachine": "Coffee Machine",
"statusBar": "{{working}} working · {{idle}} idle",
"viewTeamPanel": "View Team Panel >>"
},
"cronView": {
"title": "Scheduled Jobs",
"refresh": "Refresh",
"status": "Status:",
"enabled": "Enabled",
"disabled": "Disabled",
"jobCount": "Jobs:",
"nextWake": "Next wake:",
"jobList": "Job List",
"showCount": "Showing {{shown}} / Total {{total}}",
"loading": "Loading...",
"empty": "No scheduled jobs",
"expired": "expired"
},
"modelConfig": {
"loadFailed": "Failed to load model config: ",
"saved": "Model config saved",
"saveFailed": "Save failed: ",
"unknownError": "Unknown error",
"loading": "Loading...",
"activeConfig": "Active Configuration",
"primaryModel": "Primary model:",
"notSelected": "-- Not selected --",
"noApiKey": " (No API Key)",
"current": "Current:",
"searchPlaceholder": "Search models (filter by ID or name)",
"noMatch": "No matching providers or models",
"noProviders": "No providers yet, please add one",
"saving": "Saving...",
"saveConfig": "Save Config",
"modelCount": "{{count}} models",
"available": "Available {{ms}}ms",
"unavailable": "Unavailable",
"apiType": "API Type",
"note": "Note",
"notePlaceholder": "e.g. Company account / Personal test",
"modelList": "Model List",
"modelIdPlaceholder": "Model ID",
"customModel": "Custom Model",
"deleteConfirm": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone until you save.",
"editProvider": "Edit {{name}}",
"editProviderDesc": "Configure provider connection and manage model list",
"addModel": "Add Model",
"suggestedModels": "Suggested Models",
"done": "Done",
"reasoning": "Reasoning",
"vision": "視覺",
"providerId": "Provider ID",
"addProvider": "Add Provider",
"addProviderDesc": "Choose a known AI provider for quick setup, or create a custom one",
"searchProviderPlaceholder": "Search providers...",
"addCustomProvider": "Custom Provider",
"providerCategoryCn": "China",
"providerCategoryIntl": "International",
"providerCategoryAgg": "Aggregation",
"alreadyAdded": "Added"
},
"orchestration": {
"strategy": {
"skill-match": {
"label": "智能匹配",
"description": "根據問題內容自動選擇最相關的成員回應"
},
"coordinator": {
"label": "協調模式",
"description": "指定一位協調人分析問題並分配給合適的成員"
},
"round-robin": {
"label": "輪流發言",
"description": "成員輪流回答每個問題"
},
"all": {
"label": "全部回應",
"description": "所有成員都會回答每個問題"
}
}
},
"changelog": {
"title": "更新日誌"
},
"onboarding": {
"title": "歡迎使用 EasiestClaw",
"subtitle": "簡單設定一下,讓 AI 團隊認識你",
"avatarSectionTitle": "選擇頭像",
"nameSectionTitle": "你的名字",
"namePlaceholder": "幫自己取個名字吧",
"getStarted": "開始使用",
"skip": "暫時跳過",
"nameRequired": "請先輸入你的名字",
"extracting": "正在解壓 OpenClaw...",
"gatewayStarting": "正在啟動 OpenClaw...",
"gatewayStartingDesc": "首次啟動可能需要幾秒,如彈出防火牆提示請點擊「允許」",
"gatewayConnecting": "正在連接 Gateway...",
"gatewayReady": "Gateway 已就緒,正在載入..."
}
}
-16
View File
@@ -1,27 +1,11 @@
import ar from "./locales/ar.json"
import en from "./locales/en.json"
import fr from "./locales/fr.json"
import hi from "./locales/hi.json"
import ja from "./locales/ja.json"
import ko from "./locales/ko.json"
import ru from "./locales/ru.json"
import vi from "./locales/vi.json"
import zhCN from "./locales/zh-CN.json"
import zhTW from "./locales/zh-TW.json"
import { DEFAULT_LOCALE } from "./config"
import type { Dictionary, Locale, TranslationValues } from "./types"
const messages: Record<Locale, Dictionary> = {
"zh-CN": zhCN as Dictionary,
"zh-TW": zhTW as Dictionary,
en: en as Dictionary,
ja: ja as Dictionary,
ko: ko as Dictionary,
ru: ru as Dictionary,
ar: ar as Dictionary,
hi: hi as Dictionary,
fr: fr as Dictionary,
vi: vi as Dictionary,
}
const getMessageByKey = (dictionary: Dictionary, key: string): string | null => {
-8
View File
@@ -1,14 +1,6 @@
export const SUPPORTED_LOCALES = [
"zh-CN",
"zh-TW",
"en",
"ja",
"ko",
"ru",
"ar",
"hi",
"fr",
"vi",
] as const
export type Locale = (typeof SUPPORTED_LOCALES)[number]
+48 -3
View File
@@ -103,9 +103,26 @@ export function AppProvider({ children }: { children: ReactNode }) {
}
}, [loadFleet, loadHistory])
const checkModelsConfigured = useCallback(async () => {
try {
const res = await window.ipc.openclawModelsGet()
if (!res || !res.ok) {
dispatch({ type: "SET_MODELS_CONFIGURED", payload: false })
return
}
const result = res as { providers: Record<string, unknown>; defaults: { primary: string } }
const hasProviders = result.providers && Object.keys(result.providers).length > 0
const hasPrimary = !!result.defaults?.primary
dispatch({ type: "SET_MODELS_CONFIGURED", payload: !!(hasProviders && hasPrimary) })
} catch {
// IPC not ready yet, keep default (true) to avoid flash
}
}, [])
const { status, connect } = useRuntimeEventStream(handleEvent)
const prevStatusRef = useRef<ConnectionStatus>('disconnected')
const fleetRetryRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
dispatch({ type: "SET_CONNECTION_STATUS", payload: status })
@@ -113,9 +130,37 @@ export function AppProvider({ children }: { children: ReactNode }) {
// gateway 每次从非连接状态切换到 connected 时,自动刷新 agent 列表和历史记录
if (status === 'connected' && prevStatusRef.current !== 'connected') {
refreshFleet()
checkModelsConfigured()
// Agent 列表可能因 gateway 尚未就绪而为空,每秒重试一次,最多 20 秒
if (fleetRetryRef.current) clearInterval(fleetRetryRef.current)
let elapsed = 0
fleetRetryRef.current = setInterval(() => {
elapsed += 1000
if (stateRef.current.agents.length > 0 || elapsed >= 20000) {
if (fleetRetryRef.current) clearInterval(fleetRetryRef.current)
fleetRetryRef.current = null
return
}
refreshFleet()
}, 1000)
}
// 断开连接时清理重试
if (status !== 'connected' && fleetRetryRef.current) {
clearInterval(fleetRetryRef.current)
fleetRetryRef.current = null
}
prevStatusRef.current = status
}, [status, refreshFleet])
return () => {
if (fleetRetryRef.current) {
clearInterval(fleetRetryRef.current)
fleetRetryRef.current = null
}
}
}, [status, refreshFleet, checkModelsConfigured])
// Persist group messages to localStorage whenever messages change
// Skip until initialization is complete to avoid overwriting saved data with empty state
@@ -412,8 +457,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
)
const value = useMemo(
() => ({ state, dispatch, sendMessage, simulateAgentReply, refreshFleet, resetSession, abortConversation }),
[state, sendMessage, simulateAgentReply, refreshFleet, resetSession, abortConversation]
() => ({ state, dispatch, sendMessage, simulateAgentReply, refreshFleet, resetSession, abortConversation, checkModelsConfigured }),
[state, sendMessage, simulateAgentReply, refreshFleet, resetSession, abortConversation, checkModelsConfigured]
)
return <AppContext.Provider value={value}>{children}</AppContext.Provider>
+4
View File
@@ -11,6 +11,7 @@ export interface AppState {
connectionStatus: ConnectionStatus
gatewayConnected: boolean
mainAgentId: string | null
modelsConfigured: boolean
}
export type AppAction =
@@ -39,6 +40,7 @@ export type AppAction =
| { type: "REMOVE_AGENT"; payload: { agentId: string } }
| { type: "ADD_GROUP_MEMBER"; payload: { conversationId: string; agentId: string } }
| { type: "TOGGLE_PIN"; payload: { conversationId: string } }
| { type: "SET_MODELS_CONFIGURED"; payload: boolean }
export const initialState: AppState = {
view: "chat",
@@ -50,6 +52,7 @@ export const initialState: AppState = {
connectionStatus: "connecting",
gatewayConnected: false,
mainAgentId: null,
modelsConfigured: true,
}
export interface AppContextValue {
@@ -60,4 +63,5 @@ export interface AppContextValue {
refreshFleet: () => Promise<void>
resetSession: (conversationId: string) => void
abortConversation: (conversationId: string) => void
checkModelsConfigured: () => Promise<void>
}
+2
View File
@@ -135,6 +135,8 @@ export function handleFleetAction(state: AppState, action: AppAction): AppState
: state.activeConversationId,
}
}
case "SET_MODELS_CONFIGURED":
return { ...state, modelsConfigured: action.payload }
default:
return null
}
@@ -15,10 +15,9 @@ export function handleGatewayEvent(state: AppState, event: GatewayEvent): AppSta
const agentId = resolveAgentIdFromPayload(payload)
if (!agentId) {
console.warn(`[Reducer:GATEWAY_EVENT] could not resolve agentId from payload, sessionKey=${typeof payload.sessionKey === "string" ? payload.sessionKey : "?"}`)
// system-presence and similar events don't have agentId — silently ignore
return state
}
console.warn(`[Reducer:GATEWAY_EVENT] event=${eventName} agentId=${agentId} sessionKey=${typeof payload.sessionKey === "string" ? payload.sessionKey : "?"}`)
const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : ""
const groupMatch = sessionKey.match(/^agent:[^:]+:group:(.+)$/)