feat: 新增渠道管理页面、工作区面板、关闭确认弹窗,修复智能体详情面板
- 新增飞书 & Telegram 渠道配置页面(左侧导航 + 表单),支持读写 openclaw.json - 新增聊天工作区面板(WorkspacePanel),展示智能体文件列表 - 窗口关闭按钮增加二次确认弹窗,防止误关 - 修复智能体详情面板:模型信息改用 openclawModelsGet API,工具列表可折叠展示 - 智能体配置页 OverviewTab 同步修复模型 API 调用 - 窗口控件标题文字国际化
This commit is contained in:
1 parent
e30d2c0a62
commit
d5ca7c7dfa
19 files changed
+1532
-62
No files matched your search
@@ -21,6 +21,7 @@
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"adm-zip": "^0.5.16",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Generated
+31
@@ -20,6 +20,9 @@ importers:
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.2.6
|
||||
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-switch':
|
||||
specifier: ^1.2.6
|
||||
version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
adm-zip:
|
||||
specifier: ^0.5.16
|
||||
version: 0.5.16
|
||||
@@ -1120,6 +1123,19 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-switch@1.2.6':
|
||||
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||
peerDependencies:
|
||||
@@ -5581,6 +5597,21 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { IpcMain } from 'electron'
|
||||
import { readOpenclawConfig, writeOpenclawConfig, isRecord } from '../lib/openclaw-config'
|
||||
|
||||
export const registerChannelHandlers = (ipcMain: IpcMain): void => {
|
||||
// Read all channel configs from openclaw.json
|
||||
ipcMain.handle('openclaw:channels:get', () => {
|
||||
try {
|
||||
const config = readOpenclawConfig()
|
||||
const channels = isRecord(config.channels) ? config.channels : {}
|
||||
return { ok: true, channels }
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) }
|
||||
}
|
||||
})
|
||||
|
||||
// Write a specific channel config to openclaw.json
|
||||
ipcMain.handle(
|
||||
'openclaw:channels:set',
|
||||
(_event, params: { channelId: string; config: Record<string, unknown> }) => {
|
||||
try {
|
||||
const root = readOpenclawConfig()
|
||||
const channels = isRecord(root.channels) ? { ...root.channels } : {}
|
||||
channels[params.channelId] = params.config
|
||||
writeOpenclawConfig({ ...root, channels })
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { registerSettingsHandlers } from './settings'
|
||||
import { registerSkillsHandlers } from './skills'
|
||||
import { registerUpdateHandlers } from './update'
|
||||
import { registerProviderHealthHandlers } from './provider-health'
|
||||
import { registerChannelHandlers } from './channels'
|
||||
|
||||
export const registerAllIpcHandlers = (ipcMain: IpcMain): void => {
|
||||
registerChatHandlers(ipcMain)
|
||||
@@ -23,4 +24,5 @@ export const registerAllIpcHandlers = (ipcMain: IpcMain): void => {
|
||||
registerSkillsHandlers(ipcMain)
|
||||
registerUpdateHandlers(ipcMain)
|
||||
registerProviderHealthHandlers(ipcMain)
|
||||
registerChannelHandlers(ipcMain)
|
||||
}
|
||||
@@ -253,6 +253,11 @@ const ipcApi = {
|
||||
providerHealthCheck: (params: { baseUrl: string; apiKey: string; api: string }) =>
|
||||
ipcRenderer.invoke('provider:health-check', params),
|
||||
|
||||
// ── Channels (direct file access) ────────────────────────────────────────
|
||||
channelsGet: () => ipcRenderer.invoke('openclaw:channels:get'),
|
||||
channelsSet: (params: { channelId: string; config: Record<string, unknown> }) =>
|
||||
ipcRenderer.invoke('openclaw:channels:set', params),
|
||||
|
||||
// ── File path (Electron 32+ replaces deprecated file.path) ───────────────
|
||||
getFilePath: (file: File) => webUtils.getPathForFile(file),
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CronView } from '@/components/cron/cron-view'
|
||||
import { OpenclawView } from '@/components/openclaw/openclaw-view'
|
||||
import { SkillsView } from '@/components/skills/skills-view'
|
||||
import { AgentConfigView } from '@/components/agent-config/agent-config-view'
|
||||
import { ChannelsView } from '@/components/channels/channels-view'
|
||||
import { PortConflictDialog } from '@/components/openclaw/port-conflict-dialog'
|
||||
import { OnboardingFlow, GatewayLoadingScreen } from '@/components/onboarding/onboarding-flow'
|
||||
import { isOnboardingDone } from '@/lib/avatar'
|
||||
@@ -28,6 +29,7 @@ function MainContent() {
|
||||
case 'openclaw': return <OpenclawView />
|
||||
case 'skills': return <SkillsView />
|
||||
case 'agent-config': return <AgentConfigView />
|
||||
case 'channels': return <ChannelsView />
|
||||
default: return <ChatView />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +134,64 @@ function parseSessions(raw: unknown): AgentSession[] {
|
||||
|
||||
// ── Overview Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
type OverviewFileEntry = {
|
||||
name: string
|
||||
missing: boolean
|
||||
size?: number
|
||||
}
|
||||
|
||||
type OverviewToolEntry = {
|
||||
name: string
|
||||
description?: string
|
||||
source?: string
|
||||
pluginId?: string
|
||||
}
|
||||
|
||||
function OverviewTab({ agent, cronCount, sessionCount }: { agent: Agent; cronCount: number; sessionCount: number }) {
|
||||
const [workspace, setWorkspace] = useState("")
|
||||
const [files, setFiles] = useState<OverviewFileEntry[]>([])
|
||||
const [defaultModel, setDefaultModel] = useState("")
|
||||
const [tools, setTools] = useState<OverviewToolEntry[]>([])
|
||||
const [memorySummary, setMemorySummary] = useState("")
|
||||
const [dailyMemoryCount, setDailyMemoryCount] = useState(0)
|
||||
const [overviewLoading, setOverviewLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setOverviewLoading(true)
|
||||
Promise.all([
|
||||
window.ipc.agentsFilesList({ agentId: agent.id }),
|
||||
window.ipc.openclawModelsGet(),
|
||||
window.ipc.toolsCatalog({ agentId: agent.id }),
|
||||
window.ipc.agentsFilesGet({ agentId: agent.id, name: "MEMORY.md" }),
|
||||
window.ipc.agentsMemoryList({ agentId: agent.id }),
|
||||
]).then(([filesRes, modelsRes, toolsRes, memoryRes, dailyRes]) => {
|
||||
if (filesRes.ok) {
|
||||
const r = filesRes.result as { workspace?: string; files?: OverviewFileEntry[] }
|
||||
setWorkspace(r.workspace ?? "")
|
||||
setFiles(r.files ?? [])
|
||||
}
|
||||
{
|
||||
const result = modelsRes as {
|
||||
providers?: Record<string, { models: { id: string }[] }>
|
||||
defaults?: { primary?: string; fallbacks?: string[] }
|
||||
}
|
||||
setDefaultModel((result.defaults?.primary ?? "").trim())
|
||||
}
|
||||
if (toolsRes.ok) {
|
||||
const r = toolsRes.result as { tools?: OverviewToolEntry[]; groups?: Array<{ tools?: OverviewToolEntry[] }> }
|
||||
if (Array.isArray(r.tools)) setTools(r.tools)
|
||||
else if (Array.isArray(r.groups)) setTools(r.groups.flatMap((g) => g.tools ?? []))
|
||||
}
|
||||
if (memoryRes.ok) {
|
||||
const content = (memoryRes.result as { file?: { content?: string } })?.file?.content ?? ""
|
||||
setMemorySummary(content.slice(0, 200))
|
||||
}
|
||||
if (dailyRes.ok) {
|
||||
setDailyMemoryCount((dailyRes as { ok: true; files: unknown[] }).files?.length ?? 0)
|
||||
}
|
||||
}).finally(() => setOverviewLoading(false))
|
||||
}, [agent.id])
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto px-6 py-5 space-y-5">
|
||||
{/* Stats row */}
|
||||
@@ -185,6 +242,11 @@ function OverviewTab({ agent, cronCount, sessionCount }: { agent: Agent; cronCou
|
||||
<span>{agent.role || "—"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-20 shrink-0 text-muted-foreground text-xs">模型</span>
|
||||
<span className="font-mono text-xs">{overviewLoading ? "…" : (defaultModel || "—")}</span>
|
||||
</div>
|
||||
|
||||
{agent.lastActiveAt && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-20 shrink-0 text-muted-foreground text-xs">最近活跃</span>
|
||||
@@ -192,10 +254,104 @@ function OverviewTab({ agent, cronCount, sessionCount }: { agent: Agent; cronCou
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{overviewLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />加载中...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 工作空间 */}
|
||||
<Separator />
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
工作空间
|
||||
</h3>
|
||||
</div>
|
||||
{workspace && (
|
||||
<p className="text-[11px] text-muted-foreground font-mono break-all mb-2" title={workspace}>
|
||||
{workspace}
|
||||
</p>
|
||||
)}
|
||||
{files.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
{files.map((file) => (
|
||||
<div key={file.name} className="flex items-center justify-between gap-2 py-0.5">
|
||||
<span className="text-xs font-mono text-foreground/80 truncate">{file.name}</span>
|
||||
{file.missing ? (
|
||||
<Badge variant="outline" className="text-[9px] px-1 py-0 h-3.5 text-yellow-600 border-yellow-400 shrink-0">!</Badge>
|
||||
) : file.size != null ? (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">{formatSize(file.size)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">暂无文件</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 工具 */}
|
||||
{tools.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Zap className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
工具 ({tools.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{tools.map((tool) => (
|
||||
<div key={tool.name} className="flex items-start gap-2 py-0.5">
|
||||
<code className="text-xs font-mono text-foreground/80 shrink-0">{tool.name}</code>
|
||||
{tool.description && (
|
||||
<span className="text-[11px] text-muted-foreground truncate">{tool.description}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 记忆摘要 */}
|
||||
{(memorySummary || dailyMemoryCount > 0) && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Brain className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
记忆
|
||||
</h3>
|
||||
</div>
|
||||
{memorySummary && (
|
||||
<p className="text-xs text-muted-foreground whitespace-pre-wrap line-clamp-4 leading-relaxed mb-1.5">
|
||||
{memorySummary}{memorySummary.length >= 200 ? "…" : ""}
|
||||
</p>
|
||||
)}
|
||||
{dailyMemoryCount > 0 && (
|
||||
<p className="text-[11px] text-muted-foreground/70">{dailyMemoryCount} 条每日记忆</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
// ── Sessions Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface HistoryMsg {
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Eye, EyeOff, Loader2, Radio, Save, Send } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n"
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ChannelId = "feishu" | "telegram"
|
||||
|
||||
interface ChannelMeta {
|
||||
id: ChannelId
|
||||
icon: ReactNode
|
||||
nameKey: string
|
||||
descKey: string
|
||||
}
|
||||
|
||||
/** Feishu / Lark official logo */
|
||||
const FeishuIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 1224 1024" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1224.146926 401.768509a50.444385 50.444385 0 0 0-23.813954-38.631991c-6.095363-3.741292-61.752335-36.782364-141.475481-43.949671a317.253146 317.253146 0 0 0-135.884563 16.982943L746.964061 25.579507A50.444385 50.444385 0 0 0 703.077446 0h-418.268027A50.444385 50.444385 0 0 0 248.027055 84.97777c3.236848 3.447033 296.360763 315.739814 426.969683 459.653442-59.734559 55.762064-103.558119 83.800735-127.666331 96.832201l-200.894764-140.823909a50.045034 50.045034 0 0 0-6.97814-4.098606L79.416697 314.205464A50.444385 50.444385 0 0 0 0.744475 364.124387c0.210185 1.177036 20.619142 118.607361 42.036988 237.635091C86.815207 847.297523 91.775572 859.656397 95.054457 867.874628c5.065457 12.611096 14.334613 24.549601 44.895503 44.538188a595.916337 595.916337 0 0 0 69.361029 38.337733c49.519571 23.603769 128.212812 54.437899 221.59798 67.25918a623.009175 623.009175 0 0 0 85.061845 5.948234c131.491697 0 290.055215-44.138837 418.373119-211.404011 73.564728-96.054517 118.250046-163.944252 154.086578-218.592335 44.033745-67.070014 70.622139-107.551633 118.838564-150.177139a50.444385 50.444385 0 0 0 16.877851-42.015969zM673.693591 100.88877L834.443032 384.638437a413.097477 413.097477 0 0 0-63.055481 59.356226c-8.743693 10.04684-17.256183 19.568218-25.579507 28.711263C656.248242 373.961042 497.033151 203.332909 401.188819 100.88877zM305.491617 882.125167c-59.86067-22.594881-102.065806-47.85911-118.523287-59.692523-10.299062-45.610132-39.935138-209.638457-65.829922-355.780044l391.238243 274.270325a48.132351 48.132351 0 0 0 6.725918 3.951477l189.166445 132.689752a398.300458 398.300458 0 0 1-155.410744 44.138837c-97.336645 7.713787-188.262649-17.277202-247.366653-39.577824z m698.654734-343.442189c-34.932737 53.197808-78.398982 119.385045-149.819824 212.496972a503.371908 503.371908 0 0 1-58.641598 64.33761l-158.185184-110.830518c35.31107-23.813953 81.152405-60.070855 135.905581-114.803013a48.342536 48.342536 0 0 0 14.944149-15.154334c18.790533-19.379051 38.568936-40.859952 59.272153-64.694924 57.086229-65.745849 124.009113-96.243683 198.540692-90.673782a247.639894 247.639894 0 0 1 38.589955 6.011289c-28.290893 33.62959-51.936698 69.63427-80.605924 113.3107z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CHANNELS: ChannelMeta[] = [
|
||||
{ id: "feishu", icon: <FeishuIcon className="h-5 w-5" />, nameKey: "channels.feishu.name", descKey: "channels.feishu.desc" },
|
||||
{ id: "telegram", icon: <Send className="h-5 w-5" />, nameKey: "channels.telegram.name", descKey: "channels.telegram.desc" },
|
||||
]
|
||||
|
||||
type FeishuConfig = {
|
||||
enabled?: boolean
|
||||
domain?: string
|
||||
connectionMode?: string
|
||||
dmPolicy?: string
|
||||
groupPolicy?: string
|
||||
accounts?: Record<string, { appId?: string; appSecret?: string; botName?: string }>
|
||||
}
|
||||
|
||||
type TelegramConfig = {
|
||||
enabled?: boolean
|
||||
botToken?: string
|
||||
dmPolicy?: string
|
||||
groupPolicy?: string
|
||||
streaming?: string
|
||||
}
|
||||
|
||||
// ── Main View ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ChannelsView() {
|
||||
const { t } = useI18n()
|
||||
const [selected, setSelected] = useState<ChannelId>("feishu")
|
||||
const [allChannels, setAllChannels] = useState<Record<string, Record<string, unknown>>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Local form state
|
||||
const [feishuForm, setFeishuForm] = useState<FeishuConfig>({})
|
||||
const [telegramForm, setTelegramForm] = useState<TelegramConfig>({})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
|
||||
window.ipc.channelsGet().then((res) => {
|
||||
if (cancelled) return
|
||||
const r = res as { ok: boolean; channels?: Record<string, Record<string, unknown>> }
|
||||
if (r.ok) {
|
||||
const channels = r.channels ?? {}
|
||||
setAllChannels(channels)
|
||||
// Populate feishu form
|
||||
const fs = (channels.feishu ?? {}) as FeishuConfig
|
||||
const mainAccount = fs.accounts?.main ?? {}
|
||||
setFeishuForm({
|
||||
enabled: fs.enabled !== false,
|
||||
domain: fs.domain ?? "feishu",
|
||||
connectionMode: fs.connectionMode ?? "websocket",
|
||||
dmPolicy: fs.dmPolicy ?? "pairing",
|
||||
groupPolicy: fs.groupPolicy ?? "allowlist",
|
||||
accounts: {
|
||||
main: {
|
||||
appId: mainAccount.appId ?? "",
|
||||
appSecret: mainAccount.appSecret ?? "",
|
||||
botName: mainAccount.botName ?? "",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Populate telegram form
|
||||
const tg = (channels.telegram ?? {}) as TelegramConfig
|
||||
setTelegramForm({
|
||||
enabled: tg.enabled !== false,
|
||||
botToken: tg.botToken ?? "",
|
||||
dmPolicy: tg.dmPolicy ?? "pairing",
|
||||
groupPolicy: tg.groupPolicy ?? "allowlist",
|
||||
streaming: tg.streaming ?? "partial",
|
||||
})
|
||||
}
|
||||
}).catch(() => {
|
||||
if (!cancelled) toast.error(t("channels.loadFailed"))
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
// Build the config for the selected channel
|
||||
let config: Record<string, unknown>
|
||||
if (selected === "feishu") {
|
||||
const main = feishuForm.accounts?.main ?? {}
|
||||
if (!main.appId?.trim()) {
|
||||
toast.error(t("channels.feishu.appIdRequired"))
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
if (!main.appSecret?.trim()) {
|
||||
toast.error(t("channels.feishu.appSecretRequired"))
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
// Merge with existing config to preserve fields we don't manage
|
||||
const existing = (allChannels.feishu ?? {}) as Record<string, unknown>
|
||||
config = {
|
||||
...existing,
|
||||
enabled: feishuForm.enabled !== false,
|
||||
domain: feishuForm.domain ?? "feishu",
|
||||
connectionMode: feishuForm.connectionMode ?? "websocket",
|
||||
dmPolicy: feishuForm.dmPolicy ?? "pairing",
|
||||
groupPolicy: feishuForm.groupPolicy ?? "allowlist",
|
||||
accounts: {
|
||||
...((existing.accounts as Record<string, unknown>) ?? {}),
|
||||
main: {
|
||||
appId: main.appId!.trim(),
|
||||
appSecret: main.appSecret!.trim(),
|
||||
...(main.botName?.trim() ? { botName: main.botName.trim() } : {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
if (!telegramForm.botToken?.trim()) {
|
||||
toast.error(t("channels.telegram.botTokenRequired"))
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
const existing = (allChannels.telegram ?? {}) as Record<string, unknown>
|
||||
config = {
|
||||
...existing,
|
||||
enabled: telegramForm.enabled !== false,
|
||||
botToken: telegramForm.botToken!.trim(),
|
||||
dmPolicy: telegramForm.dmPolicy ?? "pairing",
|
||||
groupPolicy: telegramForm.groupPolicy ?? "allowlist",
|
||||
streaming: telegramForm.streaming ?? "partial",
|
||||
}
|
||||
}
|
||||
|
||||
const res = await window.ipc.channelsSet({ channelId: selected, config })
|
||||
const r = res as { ok: boolean; error?: string }
|
||||
if (r.ok) {
|
||||
toast.success(t("channels.saveSuccess"))
|
||||
// Update local cache
|
||||
setAllChannels((prev) => ({ ...prev, [selected]: config }))
|
||||
} else {
|
||||
toast.error(r.error ?? t("channels.saveFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("channels.saveFailed"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isEnabled = (id: ChannelId) => {
|
||||
const ch = allChannels[id] as { enabled?: boolean } | undefined
|
||||
return ch?.enabled === true
|
||||
}
|
||||
|
||||
const isConfigured = (id: ChannelId) => {
|
||||
if (id === "feishu") {
|
||||
const fs = allChannels.feishu as FeishuConfig | undefined
|
||||
return !!fs?.accounts?.main?.appId
|
||||
}
|
||||
const tg = allChannels.telegram as TelegramConfig | undefined
|
||||
return !!tg?.botToken
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-muted/20">
|
||||
{/* Page Header */}
|
||||
<div
|
||||
className="shrink-0 flex items-center gap-3 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 gap-3"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<div className="h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Radio className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold leading-tight">{t("channels.title")}</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{t("channels.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">{t("channels.loading")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex min-h-0">
|
||||
{/* Left: Channel list */}
|
||||
<div className="w-[200px] shrink-0 border-r bg-background overflow-y-auto py-2">
|
||||
{CHANNELS.map((ch) => (
|
||||
<button
|
||||
key={ch.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-4 py-3 text-left transition-colors",
|
||||
selected === ch.id ? "bg-accent" : "hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => setSelected(ch.id)}
|
||||
>
|
||||
<span className="text-lg shrink-0 flex items-center justify-center w-6 h-6">{ch.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium truncate">{t(ch.nameKey)}</span>
|
||||
{isConfigured(ch.id) && (
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full shrink-0",
|
||||
isEnabled(ch.id) ? "bg-green-500" : "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground truncate">{t(ch.descKey)}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right: Config form */}
|
||||
<div className="flex-1 overflow-y-auto px-8 py-6">
|
||||
{selected === "feishu" ? (
|
||||
<FeishuConfigForm
|
||||
value={feishuForm}
|
||||
onChange={setFeishuForm}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
) : (
|
||||
<TelegramConfigForm
|
||||
value={telegramForm}
|
||||
onChange={setTelegramForm}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shared form components ───────────────────────────────────────────────────
|
||||
|
||||
function FormSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FormField({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PasswordInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
}) {
|
||||
const [show, setShow] = useState(false)
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={show ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-8 text-sm font-mono pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow((v) => !v)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/60 hover:text-foreground transition-colors"
|
||||
>
|
||||
{show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PolicySelect({
|
||||
value,
|
||||
onChange,
|
||||
t,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
t: (key: string) => string
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pairing">{t("channels.policy.pairing")}</SelectItem>
|
||||
<SelectItem value="open">{t("channels.policy.open")}</SelectItem>
|
||||
<SelectItem value="allowlist">{t("channels.policy.allowlist")}</SelectItem>
|
||||
<SelectItem value="disabled">{t("channels.policy.disabled")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Feishu Config Form ───────────────────────────────────────────────────────
|
||||
|
||||
function FeishuConfigForm({
|
||||
value,
|
||||
onChange,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
value: FeishuConfig
|
||||
onChange: (v: FeishuConfig) => void
|
||||
saving: boolean
|
||||
onSave: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const main = value.accounts?.main ?? {}
|
||||
|
||||
const updateMain = (patch: Partial<typeof main>) => {
|
||||
onChange({
|
||||
...value,
|
||||
accounts: {
|
||||
...value.accounts,
|
||||
main: { ...main, ...patch },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-6">
|
||||
{/* Header with switch */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl"><FeishuIcon className="h-6 w-6" /></span>
|
||||
<h2 className="text-base font-semibold">{t("channels.feishu.name")}</h2>
|
||||
{main.appId && (
|
||||
<Badge variant={value.enabled !== false ? "default" : "secondary"} className="text-[10px] px-1.5 h-4">
|
||||
{value.enabled !== false ? t("channels.enabled") : t("channels.disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={value.enabled !== false}
|
||||
onCheckedChange={(checked) => onChange({ ...value, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Required fields */}
|
||||
<FormSection title={t("channels.requiredFields")}>
|
||||
<FormField label="App ID" required>
|
||||
<Input
|
||||
value={main.appId ?? ""}
|
||||
onChange={(e) => updateMain({ appId: e.target.value })}
|
||||
placeholder="cli_a1b2c3d4e5f6"
|
||||
className="h-8 text-sm font-mono"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="App Secret" required>
|
||||
<PasswordInput
|
||||
value={main.appSecret ?? ""}
|
||||
onChange={(v) => updateMain({ appSecret: v })}
|
||||
placeholder={t("channels.feishu.appSecretPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("channels.feishu.setupHint")}
|
||||
</p>
|
||||
|
||||
{/* Optional fields */}
|
||||
<FormSection title={t("channels.optionalFields")}>
|
||||
<FormField label={t("channels.feishu.botName")}>
|
||||
<Input
|
||||
value={main.botName ?? ""}
|
||||
onChange={(e) => updateMain({ botName: e.target.value })}
|
||||
placeholder={t("channels.feishu.botNamePlaceholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("channels.feishu.domain")}>
|
||||
<Select
|
||||
value={value.domain ?? "feishu"}
|
||||
onValueChange={(v) => onChange({ ...value, domain: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="feishu">{t("channels.feishu.domainFeishu")}</SelectItem>
|
||||
<SelectItem value="lark">{t("channels.feishu.domainLark")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("channels.feishu.connectionMode")}>
|
||||
<Select
|
||||
value={value.connectionMode ?? "websocket"}
|
||||
onValueChange={(v) => onChange({ ...value, connectionMode: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="websocket">WebSocket</SelectItem>
|
||||
<SelectItem value="webhook">Webhook</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("channels.dmPolicy")}>
|
||||
<PolicySelect
|
||||
value={value.dmPolicy ?? "pairing"}
|
||||
onChange={(v) => onChange({ ...value, dmPolicy: v })}
|
||||
t={t}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("channels.groupPolicy")}>
|
||||
<PolicySelect
|
||||
value={value.groupPolicy ?? "allowlist"}
|
||||
onChange={(v) => onChange({ ...value, groupPolicy: v })}
|
||||
t={t}
|
||||
/>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button size="sm" onClick={onSave} disabled={saving} className="gap-1.5 text-xs h-8">
|
||||
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />}
|
||||
{t("channels.save")}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">{t("channels.restartHint")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Telegram Config Form ─────────────────────────────────────────────────────
|
||||
|
||||
function TelegramConfigForm({
|
||||
value,
|
||||
onChange,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
value: TelegramConfig
|
||||
onChange: (v: TelegramConfig) => void
|
||||
saving: boolean
|
||||
onSave: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-6">
|
||||
{/* Header with switch */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl"><Send className="h-6 w-6" /></span>
|
||||
<h2 className="text-base font-semibold">{t("channels.telegram.name")}</h2>
|
||||
{value.botToken && (
|
||||
<Badge variant={value.enabled !== false ? "default" : "secondary"} className="text-[10px] px-1.5 h-4">
|
||||
{value.enabled !== false ? t("channels.enabled") : t("channels.disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={value.enabled !== false}
|
||||
onCheckedChange={(checked) => onChange({ ...value, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Required fields */}
|
||||
<FormSection title={t("channels.requiredFields")}>
|
||||
<FormField label="Bot Token" required>
|
||||
<PasswordInput
|
||||
value={value.botToken ?? ""}
|
||||
onChange={(v) => onChange({ ...value, botToken: v })}
|
||||
placeholder="123456789:ABCDefGHiJKlmnoPQRstuvwXYZ"
|
||||
/>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("channels.telegram.setupHint")}
|
||||
</p>
|
||||
|
||||
{/* Optional fields */}
|
||||
<FormSection title={t("channels.optionalFields")}>
|
||||
<FormField label={t("channels.dmPolicy")}>
|
||||
<PolicySelect
|
||||
value={value.dmPolicy ?? "pairing"}
|
||||
onChange={(v) => onChange({ ...value, dmPolicy: v })}
|
||||
t={t}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("channels.groupPolicy")}>
|
||||
<PolicySelect
|
||||
value={value.groupPolicy ?? "allowlist"}
|
||||
onChange={(v) => onChange({ ...value, groupPolicy: v })}
|
||||
t={t}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("channels.telegram.streaming")}>
|
||||
<Select
|
||||
value={value.streaming ?? "partial"}
|
||||
onValueChange={(v) => onChange({ ...value, streaming: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="off">{t("channels.telegram.streamingOff")}</SelectItem>
|
||||
<SelectItem value="partial">{t("channels.telegram.streamingPartial")}</SelectItem>
|
||||
<SelectItem value="block">{t("channels.telegram.streamingBlock")}</SelectItem>
|
||||
<SelectItem value="progress">{t("channels.telegram.streamingProgress")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button size="sm" onClick={onSave} disabled={saving} className="gap-1.5 text-xs h-8">
|
||||
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />}
|
||||
{t("channels.save")}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">{t("channels.restartHint")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Brain, Clock, FolderOpen, Loader2, Server, Wrench } from "lucide-react"
|
||||
import { Brain, ChevronDown, ChevronRight, Clock, FolderOpen, Loader2, Server, Wrench } from "lucide-react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
@@ -74,11 +74,11 @@ export function AgentInfoSheet({ agent, open, onOpenChange }: AgentInfoSheetProp
|
||||
Promise.all([
|
||||
window.ipc.agentsFilesList({ agentId: agent.id }),
|
||||
window.ipc.cronList(),
|
||||
window.ipc.configGet(),
|
||||
window.ipc.openclawModelsGet(),
|
||||
window.ipc.toolsCatalog({ agentId: agent.id }),
|
||||
window.ipc.agentsFilesGet({ agentId: agent.id, name: "MEMORY.md" }),
|
||||
window.ipc.agentsMemoryList({ agentId: agent.id }),
|
||||
]).then(([filesRes, cronRes, configRes, toolsRes, memoryRes, dailyRes]) => {
|
||||
]).then(([filesRes, cronRes, modelsRes, toolsRes, memoryRes, dailyRes]) => {
|
||||
if (filesRes.ok) {
|
||||
const r = filesRes.result as { workspace?: string; files?: FileEntry[] }
|
||||
setWorkspace(r.workspace ?? "")
|
||||
@@ -88,11 +88,12 @@ export function AgentInfoSheet({ agent, open, onOpenChange }: AgentInfoSheetProp
|
||||
const list = cronRes.result as { jobs: CronJob[] }
|
||||
setCronJobs((list.jobs ?? []).filter((j) => j.agentId === agent.id))
|
||||
}
|
||||
if (configRes.ok) {
|
||||
const cfg = configRes.result as {
|
||||
parsed?: { models?: { defaults?: { primary?: string } } }
|
||||
{
|
||||
const result = modelsRes as {
|
||||
providers?: Record<string, { models: { id: string }[] }>
|
||||
defaults?: { primary?: string; fallbacks?: string[] }
|
||||
}
|
||||
setDefaultModel(cfg.parsed?.models?.defaults?.primary ?? "")
|
||||
setDefaultModel((result.defaults?.primary ?? "").trim())
|
||||
}
|
||||
if (toolsRes.ok) {
|
||||
// tools.catalog may return { tools: ToolEntry[] } or { groups: { tools: ToolEntry[] }[] }
|
||||
@@ -157,27 +158,7 @@ export function AgentInfoSheet({ agent, open, onOpenChange }: AgentInfoSheetProp
|
||||
</Section>
|
||||
|
||||
{/* 工具 */}
|
||||
<Section icon={<Wrench className="h-3.5 w-3.5" />} title={`工具 (${tools.length})`}>
|
||||
{tools.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tools.map((tool) => (
|
||||
<Badge
|
||||
key={tool.name}
|
||||
variant="secondary"
|
||||
className="text-[11px] px-1.5 py-0.5 h-auto font-mono"
|
||||
title={tool.description}
|
||||
>
|
||||
{tool.name}
|
||||
{tool.source === "plugin" && tool.pluginId && (
|
||||
<span className="ml-1 opacity-50 text-[9px]">plug</span>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">暂无工具数据</p>
|
||||
)}
|
||||
</Section>
|
||||
<CollapsibleToolsSection tools={tools} />
|
||||
|
||||
{/* 记忆 */}
|
||||
<Section icon={<Brain className="h-3.5 w-3.5" />} title="记忆">
|
||||
@@ -265,6 +246,54 @@ export function AgentInfoSheet({ agent, open, onOpenChange }: AgentInfoSheetProp
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleToolsSection({ tools }: { tools: ToolEntry[] }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const previewCount = 5
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
<button
|
||||
className="flex items-center gap-1.5 mb-2 w-full text-left"
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
>
|
||||
{tools.length > 0 ? (
|
||||
expanded ? (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
)
|
||||
) : null}
|
||||
<Wrench className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
工具 ({tools.length})
|
||||
</h3>
|
||||
</button>
|
||||
{tools.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{(expanded ? tools : tools.slice(0, previewCount)).map((tool) => (
|
||||
<div key={tool.name} className="flex items-start gap-2 py-0.5">
|
||||
<code className="text-xs font-mono text-foreground/80 shrink-0">{tool.name}</code>
|
||||
{tool.description && (
|
||||
<span className="text-[11px] text-muted-foreground truncate">{tool.description}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!expanded && tools.length > previewCount && (
|
||||
<button
|
||||
className="text-[11px] text-primary hover:underline"
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
展开全部 {tools.length} 个工具
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">暂无工具数据</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({
|
||||
icon,
|
||||
title,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
import { Loader2, MoreHorizontal, Trash2, Users } from "lucide-react"
|
||||
import { FolderOpen, Loader2, MoreHorizontal, Trash2, Users } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
@@ -38,10 +38,11 @@ import type { Conversation } from "@/types"
|
||||
interface ChatHeaderProps {
|
||||
conversation: Conversation
|
||||
onToggleMembers?: () => void
|
||||
onToggleWorkspace?: () => void
|
||||
onAgentAvatarClick?: (agentId: string, agentName: string) => void
|
||||
}
|
||||
|
||||
export function ChatHeader({ conversation, onToggleMembers, onAgentAvatarClick }: ChatHeaderProps) {
|
||||
export function ChatHeader({ conversation, onToggleMembers, onToggleWorkspace, onAgentAvatarClick }: ChatHeaderProps) {
|
||||
const { state, dispatch, refreshFleet } = useApp()
|
||||
const { t } = useI18n()
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
@@ -157,6 +158,14 @@ export function ChatHeader({ conversation, onToggleMembers, onAgentAvatarClick }
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isGroup && agent && onToggleWorkspace && (
|
||||
<HeaderButton
|
||||
icon={<FolderOpen className="h-4 w-4" />}
|
||||
tooltip={t("header.tooltips.workspace")}
|
||||
onClick={onToggleWorkspace}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground" />}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MessageInput } from "./message-input"
|
||||
import { TypingIndicator } from "./typing-indicator"
|
||||
import { GroupMembersPanel } from "./group-members-panel"
|
||||
import { PersonaPanel } from "@/components/persona/persona-panel"
|
||||
import { WorkspacePanel } from "./workspace-panel"
|
||||
import { useI18n } from "@/i18n"
|
||||
import type { Message } from "@/types"
|
||||
|
||||
@@ -62,6 +63,7 @@ export function ChatWindow() {
|
||||
const { t } = useI18n()
|
||||
useAvatarVersion() // re-render when avatar changes
|
||||
const [showMembers, setShowMembers] = useState(false)
|
||||
const [showWorkspace, setShowWorkspace] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const [personaPanelOpen, setPersonaPanelOpen] = useState(false)
|
||||
const [personaAgentId, setPersonaAgentId] = useState("")
|
||||
@@ -232,6 +234,7 @@ export function ChatWindow() {
|
||||
<ChatHeader
|
||||
conversation={conversation}
|
||||
onToggleMembers={isGroup ? () => setShowMembers((p) => !p) : undefined}
|
||||
onToggleWorkspace={!isGroup ? () => setShowWorkspace((p) => !p) : undefined}
|
||||
onAgentAvatarClick={handleAgentAvatarClick}
|
||||
/>
|
||||
|
||||
@@ -308,6 +311,14 @@ export function ChatWindow() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isGroup && conversation.members[0] && (
|
||||
<WorkspacePanel
|
||||
agentId={conversation.members[0]}
|
||||
open={showWorkspace}
|
||||
onOpenChange={setShowWorkspace}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PersonaPanel
|
||||
open={personaPanelOpen}
|
||||
onOpenChange={setPersonaPanelOpen}
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
PanelRightClose,
|
||||
Save,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n"
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type BootstrapFile = {
|
||||
name: string
|
||||
path: string
|
||||
missing: boolean
|
||||
size?: number
|
||||
updatedAtMs?: number
|
||||
}
|
||||
|
||||
type MemoryFile = {
|
||||
name: string
|
||||
size: number
|
||||
updatedAtMs: number
|
||||
}
|
||||
|
||||
type SelectedFile =
|
||||
| { source: "bootstrap"; name: string }
|
||||
| { source: "memory"; name: string }
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
// ── WorkspacePanel ───────────────────────────────────────────────────────────
|
||||
|
||||
interface WorkspacePanelProps {
|
||||
agentId: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
// data
|
||||
const [workspace, setWorkspace] = useState("")
|
||||
const [bootstrapFiles, setBootstrapFiles] = useState<BootstrapFile[]>([])
|
||||
const [memoryFiles, setMemoryFiles] = useState<MemoryFile[]>([])
|
||||
const [loadingList, setLoadingList] = useState(false)
|
||||
|
||||
// tree state
|
||||
const [memoryExpanded, setMemoryExpanded] = useState(false)
|
||||
const [selected, setSelected] = useState<SelectedFile | null>(null)
|
||||
|
||||
// preview state
|
||||
const [content, setContent] = useState("")
|
||||
const [editContent, setEditContent] = useState("")
|
||||
const [loadingFile, setLoadingFile] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
const isMemoryFile = selected?.source === "memory"
|
||||
const selectedName = selected?.name ?? ""
|
||||
|
||||
// ── Load file list ──────────────────────────────────────────────────────
|
||||
|
||||
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 ?? [])
|
||||
}
|
||||
} finally {
|
||||
setLoadingList(false)
|
||||
}
|
||||
}, [agentId])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && agentId) {
|
||||
setSelected(null)
|
||||
setContent("")
|
||||
setEditContent("")
|
||||
setDirty(false)
|
||||
loadFiles()
|
||||
}
|
||||
}, [open, agentId, loadFiles])
|
||||
|
||||
// ── Load file content ───────────────────────────────────────────────────
|
||||
|
||||
const loadFileContent = useCallback(
|
||||
async (file: SelectedFile) => {
|
||||
setLoadingFile(true)
|
||||
setDirty(false)
|
||||
try {
|
||||
if (file.source === "bootstrap") {
|
||||
const res = await window.ipc.agentsFilesGet({ agentId, name: file.name })
|
||||
if (res.ok) {
|
||||
const c = (res.result as { file?: { content?: string } })?.file?.content ?? ""
|
||||
setContent(c)
|
||||
setEditContent(c)
|
||||
} else {
|
||||
setContent("")
|
||||
setEditContent("")
|
||||
}
|
||||
} else {
|
||||
const res = await window.ipc.agentsMemoryGet({ agentId, name: file.name })
|
||||
if (res.ok) {
|
||||
const c = (res as { ok: true; content?: string }).content ?? ""
|
||||
setContent(c)
|
||||
setEditContent(c)
|
||||
} else {
|
||||
setContent("")
|
||||
setEditContent("")
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoadingFile(false)
|
||||
}
|
||||
},
|
||||
[agentId]
|
||||
)
|
||||
|
||||
const handleSelectFile = (file: SelectedFile) => {
|
||||
setSelected(file)
|
||||
loadFileContent(file)
|
||||
}
|
||||
|
||||
// ── Save ────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selected || selected.source !== "bootstrap") return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await window.ipc.agentsFilesSet({
|
||||
agentId,
|
||||
name: selected.name,
|
||||
content: editContent,
|
||||
})
|
||||
if (res.ok) {
|
||||
toast.success(`${selected.name} ${t("workspace.saved")}`)
|
||||
setContent(editContent)
|
||||
setDirty(false)
|
||||
loadFiles()
|
||||
} else {
|
||||
toast.error((res as { error?: string }).error ?? t("workspace.saveFailed"))
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 下右侧留出窗口控件的空间,避免关闭按钮和系统按钮重叠 */}
|
||||
<div
|
||||
className="h-12 flex items-center justify-between px-3 border-b shrink-0"
|
||||
style={{
|
||||
...(window.ipc.platform !== "darwin" && { paddingRight: "154px" }),
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium truncate">{t("workspace.title")}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Workspace path */}
|
||||
{workspace && (
|
||||
<div className="px-3 py-1.5 border-b">
|
||||
<p className="text-[11px] text-muted-foreground font-mono truncate" title={workspace}>
|
||||
{workspace}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingList ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">{t("workspace.loading")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* File list — 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" />
|
||||
) : (
|
||||
<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 })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Content preview — fills remaining space */}
|
||||
{selected ? (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* File header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b shrink-0">
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
{!isMemoryFile && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 gap-1 text-xs px-2"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3 w-3" />
|
||||
)}
|
||||
{t("workspace.save")}
|
||||
</Button>
|
||||
)}
|
||||
{isMemoryFile && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4">
|
||||
{t("workspace.readonly")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loadingFile ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
className="flex-1 resize-none font-mono text-xs min-h-0 border-0 rounded-none focus-visible:ring-0"
|
||||
value={editContent}
|
||||
onChange={(e) => {
|
||||
setEditContent(e.target.value)
|
||||
setDirty(e.target.value !== content)
|
||||
}}
|
||||
readOnly={isMemoryFile}
|
||||
placeholder={
|
||||
isMemoryFile
|
||||
? t("workspace.emptyMemory")
|
||||
: t("workspace.emptyFile")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<p className="text-xs">{t("workspace.selectFile")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Activity, Bot, Clock, Loader2, MessageSquare, MessageSquarePlus, Puzzle, Settings, UserPlus, Users } from "lucide-react"
|
||||
import { Activity, Bot, Clock, Loader2, MessageSquare, MessageSquarePlus, Puzzle, Radio, Settings, UserPlus, Users } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -135,6 +135,11 @@ export function NavRail() {
|
||||
label: "Agent 配置",
|
||||
icon: <Bot className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
id: "channels",
|
||||
label: t("nav.channels"),
|
||||
icon: <Radio className="h-5 w-5" />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
|
||||
import { Minus, Square, X } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useI18n } from "@/i18n"
|
||||
|
||||
// 悬浮窗口控制按钮,fixed 定位在右上角,自身无背景,不破坏竖向色块连贯性
|
||||
export function WindowControls() {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false)
|
||||
const { t } = useI18n()
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.ipc.onMaximizedChanged((v) => setIsMaximized(v))
|
||||
@@ -14,31 +27,53 @@ export function WindowControls() {
|
||||
if (window.ipc.platform === "darwin") return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 right-0 flex items-center z-50"
|
||||
style={{ height: "48px", WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<button
|
||||
className="w-[46px] h-full flex items-center justify-center text-muted-foreground/50 hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() => window.ipc.windowMinimize()}
|
||||
title="最小化"
|
||||
<>
|
||||
<div
|
||||
className="fixed top-0 right-0 flex items-center z-50"
|
||||
style={{ height: "48px", WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
className="w-[46px] h-full flex items-center justify-center text-muted-foreground/50 hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() => window.ipc.windowMaximize()}
|
||||
title={isMaximized ? "还原" : "最大化"}
|
||||
>
|
||||
<Square className="h-[11px] w-[11px]" />
|
||||
</button>
|
||||
<button
|
||||
className="w-[46px] h-full flex items-center justify-center text-muted-foreground/50 hover:bg-red-500 hover:text-white transition-colors"
|
||||
onClick={() => window.ipc.windowClose()}
|
||||
title="关闭"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="w-[46px] h-full flex items-center justify-center text-muted-foreground/50 hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() => window.ipc.windowMinimize()}
|
||||
title={t("windowControls.minimize")}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
className="w-[46px] h-full flex items-center justify-center text-muted-foreground/50 hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() => window.ipc.windowMaximize()}
|
||||
title={isMaximized ? t("windowControls.restore") : t("windowControls.maximize")}
|
||||
>
|
||||
<Square className="h-[11px] w-[11px]" />
|
||||
</button>
|
||||
<button
|
||||
className="w-[46px] h-full flex items-center justify-center text-muted-foreground/50 hover:bg-red-500 hover:text-white transition-colors"
|
||||
onClick={() => setCloseConfirmOpen(true)}
|
||||
title={t("windowControls.close")}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={closeConfirmOpen} onOpenChange={setCloseConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("windowControls.closeConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("windowControls.closeConfirmDesc")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => window.ipc.windowClose()}
|
||||
>
|
||||
{t("windowControls.confirmClose")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 items-center rounded-full border-2 border-transparent shadow-xs transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform",
|
||||
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -26,7 +26,8 @@
|
||||
"openclaw": "OpenClaw",
|
||||
"settings": "Settings",
|
||||
"changeMyAvatar": "Change my avatar",
|
||||
"changeMyAvatarDescription": "After you confirm, a file picker opens and the selected image replaces your current avatar immediately."
|
||||
"changeMyAvatarDescription": "After you confirm, a file picker opens and the selected image replaces your current avatar immediately.",
|
||||
"channels": "Channels"
|
||||
},
|
||||
"topNav": {
|
||||
"connection": {
|
||||
@@ -180,7 +181,8 @@
|
||||
"voiceCall": "Voice call",
|
||||
"videoCall": "Video call",
|
||||
"searchMessages": "Search messages",
|
||||
"membersList": "Member list"
|
||||
"membersList": "Member list",
|
||||
"workspace": "Workspace"
|
||||
},
|
||||
"menu": {
|
||||
"viewProfile": "View profile",
|
||||
@@ -343,5 +345,72 @@
|
||||
"upgradeTo": "New version",
|
||||
"upgradeConfirm": "Upgrade Now",
|
||||
"upgradeSkip": "Skip for Now"
|
||||
},
|
||||
"windowControls": {
|
||||
"minimize": "Minimize",
|
||||
"maximize": "Maximize",
|
||||
"restore": "Restore",
|
||||
"close": "Close",
|
||||
"closeConfirmTitle": "Confirm Exit",
|
||||
"closeConfirmDesc": "Closing the window will stop all AI team tasks. Are you sure you want to exit?",
|
||||
"confirmClose": "Exit"
|
||||
},
|
||||
"workspace": {
|
||||
"title": "Workspace",
|
||||
"loading": "Loading...",
|
||||
"save": "Save",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Save failed",
|
||||
"readonly": "Read-only",
|
||||
"selectFile": "Select a file to view",
|
||||
"emptyFile": "File is empty. Type something and save.",
|
||||
"emptyMemory": "No content"
|
||||
},
|
||||
"channels": {
|
||||
"title": "Channels",
|
||||
"description": "Configure external messaging channels (Feishu, Telegram, etc.) to connect your AI team to more platforms",
|
||||
"loading": "Loading...",
|
||||
"loadFailed": "Failed to load channel config",
|
||||
"save": "Save",
|
||||
"saveSuccess": "Channel config saved",
|
||||
"saveFailed": "Save failed",
|
||||
"restartHint": "Restart OpenClaw for changes to take effect",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"requiredFields": "Required",
|
||||
"optionalFields": "Optional",
|
||||
"dmPolicy": "DM Policy",
|
||||
"groupPolicy": "Group Policy",
|
||||
"policy": {
|
||||
"pairing": "Pairing",
|
||||
"open": "Open",
|
||||
"allowlist": "Allowlist",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"feishu": {
|
||||
"name": "Feishu",
|
||||
"desc": "Feishu / Lark Bot",
|
||||
"appIdRequired": "App ID is required",
|
||||
"appSecretRequired": "App Secret is required",
|
||||
"appSecretPlaceholder": "From Feishu Open Platform",
|
||||
"setupHint": "Go to open.feishu.cn/app to create an app and get App ID & App Secret. WebSocket connection is recommended.",
|
||||
"botName": "Bot Name",
|
||||
"botNamePlaceholder": "Display name in messages",
|
||||
"domain": "Platform",
|
||||
"domainFeishu": "Feishu (China)",
|
||||
"domainLark": "Lark (Global)",
|
||||
"connectionMode": "Connection Mode"
|
||||
},
|
||||
"telegram": {
|
||||
"name": "Telegram",
|
||||
"desc": "Telegram Bot",
|
||||
"botTokenRequired": "Bot Token is required",
|
||||
"setupHint": "Find @BotFather on Telegram, send /newbot to create a bot and get the token.",
|
||||
"streaming": "Streaming",
|
||||
"streamingOff": "Off",
|
||||
"streamingPartial": "Partial (Recommended)",
|
||||
"streamingBlock": "Block",
|
||||
"streamingProgress": "Progress"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,8 @@
|
||||
"openclaw": "OpenClaw",
|
||||
"settings": "设置",
|
||||
"changeMyAvatar": "更换个人头像",
|
||||
"changeMyAvatarDescription": "确认后将打开文件选择器,选择图片后会立即替换当前头像。"
|
||||
"changeMyAvatarDescription": "确认后将打开文件选择器,选择图片后会立即替换当前头像。",
|
||||
"channels": "渠道"
|
||||
},
|
||||
"topNav": {
|
||||
"connection": {
|
||||
@@ -180,7 +181,8 @@
|
||||
"voiceCall": "语音通话",
|
||||
"videoCall": "视频通话",
|
||||
"searchMessages": "搜索聊天记录",
|
||||
"membersList": "成员列表"
|
||||
"membersList": "成员列表",
|
||||
"workspace": "工作空间"
|
||||
},
|
||||
"menu": {
|
||||
"viewProfile": "查看资料",
|
||||
@@ -343,5 +345,72 @@
|
||||
"upgradeTo": "新版本",
|
||||
"upgradeConfirm": "立即升级",
|
||||
"upgradeSkip": "暂时跳过"
|
||||
},
|
||||
"windowControls": {
|
||||
"minimize": "最小化",
|
||||
"maximize": "最大化",
|
||||
"restore": "还原",
|
||||
"close": "关闭",
|
||||
"closeConfirmTitle": "确认退出",
|
||||
"closeConfirmDesc": "关闭窗口将同时停止所有 AI 团队任务,确定要退出吗?",
|
||||
"confirmClose": "退出"
|
||||
},
|
||||
"workspace": {
|
||||
"title": "工作空间",
|
||||
"loading": "加载中...",
|
||||
"save": "保存",
|
||||
"saved": "已保存",
|
||||
"saveFailed": "保存失败",
|
||||
"readonly": "只读",
|
||||
"selectFile": "选择文件查看内容",
|
||||
"emptyFile": "文件为空,输入内容后点击保存",
|
||||
"emptyMemory": "暂无内容"
|
||||
},
|
||||
"channels": {
|
||||
"title": "渠道管理",
|
||||
"description": "配置外部消息渠道(飞书、Telegram 等),让 AI 团队接入更多平台",
|
||||
"loading": "加载中...",
|
||||
"loadFailed": "加载渠道配置失败",
|
||||
"save": "保存配置",
|
||||
"saveSuccess": "渠道配置已保存",
|
||||
"saveFailed": "保存失败",
|
||||
"restartHint": "保存后需重启 OpenClaw 使配置生效",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已停用",
|
||||
"requiredFields": "必填配置",
|
||||
"optionalFields": "可选配置",
|
||||
"dmPolicy": "私聊策略",
|
||||
"groupPolicy": "群聊策略",
|
||||
"policy": {
|
||||
"pairing": "配对模式",
|
||||
"open": "开放",
|
||||
"allowlist": "白名单",
|
||||
"disabled": "禁用"
|
||||
},
|
||||
"feishu": {
|
||||
"name": "飞书",
|
||||
"desc": "飞书 / Lark 机器人",
|
||||
"appIdRequired": "请填写 App ID",
|
||||
"appSecretRequired": "请填写 App Secret",
|
||||
"appSecretPlaceholder": "从飞书开放平台获取",
|
||||
"setupHint": "前往 open.feishu.cn/app 创建应用,获取 App ID 和 App Secret。连接方式推荐 WebSocket。",
|
||||
"botName": "机器人名称",
|
||||
"botNamePlaceholder": "显示在消息中的名称",
|
||||
"domain": "平台",
|
||||
"domainFeishu": "飞书(中国)",
|
||||
"domainLark": "Lark(国际)",
|
||||
"connectionMode": "连接方式"
|
||||
},
|
||||
"telegram": {
|
||||
"name": "Telegram",
|
||||
"desc": "Telegram Bot",
|
||||
"botTokenRequired": "请填写 Bot Token",
|
||||
"setupHint": "在 Telegram 中找 @BotFather,发送 /newbot 创建机器人并获取 Token。",
|
||||
"streaming": "流式输出",
|
||||
"streamingOff": "关闭",
|
||||
"streamingPartial": "部分(推荐)",
|
||||
"streamingBlock": "分块",
|
||||
"streamingProgress": "进度条"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ let _idCounter = 0
|
||||
export const uniqueId = (prefix: string): string =>
|
||||
`${prefix}-${Date.now()}-${(++_idCounter).toString(36)}`
|
||||
|
||||
export const VALID_VIEWS: ViewType[] = ["chat", "virtual-team", "cron", "openclaw", "skills", "agent-config"]
|
||||
export const VALID_VIEWS: ViewType[] = ["chat", "virtual-team", "cron", "openclaw", "skills", "agent-config", "channels"]
|
||||
|
||||
export const parseViewFromHash = (): ViewType => {
|
||||
if (typeof window === "undefined") return "chat"
|
||||
|
||||
@@ -85,4 +85,4 @@ export interface ChatAttachment {
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
export type ViewType = "chat" | "virtual-team" | "cron" | "openclaw" | "skills" | "agent-config"
|
||||
export type ViewType = "chat" | "virtual-team" | "cron" | "openclaw" | "skills" | "agent-config" | "channels"
|
||||
Reference in new issue
Block a user