feat: 技能市场、工作空间文件树、模型配置引导、i18n精简
- 新增 ClawHub 技能市场:浏览、搜索、安装技能(npx clawhub install) - 工作空间面板重写:递归显示完整文件树,支持默认应用打开文件 - 引导流程新增模型配置检测步骤,未配置时设置按钮红点提醒 - i18n 精简:仅保留 zh-CN 和 en 两种语言 - 修复会话列表空白闪烁、gateway 事件字段兼容等问题
This commit is contained in:
1 parent
623cf72555
commit
28e0503cbe
29 files changed
+1746
-2769
No files matched your search
+10
-6
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 جاهز، جارٍ التحميل..."
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
@@ -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 तैयार है, लोड हो रहा है..."
|
||||
}
|
||||
}
|
||||
@@ -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 準備完了、読み込み中..."
|
||||
}
|
||||
}
|
||||
@@ -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 준비 완료, 로딩 중..."
|
||||
}
|
||||
}
|
||||
@@ -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 готов, загрузка..."
|
||||
}
|
||||
}
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
@@ -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": "此技能已被标记为可疑"
|
||||
}
|
||||
}
|
||||
@@ -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 已就緒,正在載入..."
|
||||
}
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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:(.+)$/)
|
||||
|
||||
Reference in new issue
Block a user