feat: 新增渠道管理页面、工作区面板、关闭确认弹窗,修复智能体详情面板

- 新增飞书 & Telegram 渠道配置页面(左侧导航 + 表单),支持读写 openclaw.json
- 新增聊天工作区面板(WorkspacePanel),展示智能体文件列表
- 窗口关闭按钮增加二次确认弹窗,防止误关
- 修复智能体详情面板:模型信息改用 openclawModelsGet API,工具列表可折叠展示
- 智能体配置页 OverviewTab 同步修复模型 API 调用
- 窗口控件标题文字国际化
This commit is contained in:
钟明原 committed 2026-03-16 14:23:10 +08:00
1 parent e30d2c0a62
commit d5ca7c7dfa
19 files changed
+1532 -62

No files matched your search

+1
View File
@@ -21,6 +21,7 @@
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^3.0.0",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-switch": "^1.2.6",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+31
View File
@@ -20,6 +20,9 @@ importers:
'@radix-ui/react-select': '@radix-ui/react-select':
specifier: ^2.2.6 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) 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: adm-zip:
specifier: ^0.5.16 specifier: ^0.5.16
version: 0.5.16 version: 0.5.16
@@ -1120,6 +1123,19 @@ packages:
'@types/react': '@types/react':
optional: true 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': '@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies: peerDependencies:
@@ -5581,6 +5597,21 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.14 '@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)': '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.3)':
dependencies: dependencies:
react: 19.2.3 react: 19.2.3
+31
View File
@@ -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) }
}
},
)
}
+2
View File
@@ -10,6 +10,7 @@ import { registerSettingsHandlers } from './settings'
import { registerSkillsHandlers } from './skills' import { registerSkillsHandlers } from './skills'
import { registerUpdateHandlers } from './update' import { registerUpdateHandlers } from './update'
import { registerProviderHealthHandlers } from './provider-health' import { registerProviderHealthHandlers } from './provider-health'
import { registerChannelHandlers } from './channels'
export const registerAllIpcHandlers = (ipcMain: IpcMain): void => { export const registerAllIpcHandlers = (ipcMain: IpcMain): void => {
registerChatHandlers(ipcMain) registerChatHandlers(ipcMain)
@@ -23,4 +24,5 @@ export const registerAllIpcHandlers = (ipcMain: IpcMain): void => {
registerSkillsHandlers(ipcMain) registerSkillsHandlers(ipcMain)
registerUpdateHandlers(ipcMain) registerUpdateHandlers(ipcMain)
registerProviderHealthHandlers(ipcMain) registerProviderHealthHandlers(ipcMain)
registerChannelHandlers(ipcMain)
} }
+5
View File
@@ -253,6 +253,11 @@ const ipcApi = {
providerHealthCheck: (params: { baseUrl: string; apiKey: string; api: string }) => providerHealthCheck: (params: { baseUrl: string; apiKey: string; api: string }) =>
ipcRenderer.invoke('provider:health-check', params), 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) ─────────────── // ── File path (Electron 32+ replaces deprecated file.path) ───────────────
getFilePath: (file: File) => webUtils.getPathForFile(file), getFilePath: (file: File) => webUtils.getPathForFile(file),
} }
+2
View File
@@ -12,6 +12,7 @@ import { CronView } from '@/components/cron/cron-view'
import { OpenclawView } from '@/components/openclaw/openclaw-view' import { OpenclawView } from '@/components/openclaw/openclaw-view'
import { SkillsView } from '@/components/skills/skills-view' import { SkillsView } from '@/components/skills/skills-view'
import { AgentConfigView } from '@/components/agent-config/agent-config-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 { PortConflictDialog } from '@/components/openclaw/port-conflict-dialog'
import { OnboardingFlow, GatewayLoadingScreen } from '@/components/onboarding/onboarding-flow' import { OnboardingFlow, GatewayLoadingScreen } from '@/components/onboarding/onboarding-flow'
import { isOnboardingDone } from '@/lib/avatar' import { isOnboardingDone } from '@/lib/avatar'
@@ -28,6 +29,7 @@ function MainContent() {
case 'openclaw': return <OpenclawView /> case 'openclaw': return <OpenclawView />
case 'skills': return <SkillsView /> case 'skills': return <SkillsView />
case 'agent-config': return <AgentConfigView /> case 'agent-config': return <AgentConfigView />
case 'channels': return <ChannelsView />
default: return <ChatView /> default: return <ChatView />
} }
} }
@@ -134,7 +134,64 @@ function parseSessions(raw: unknown): AgentSession[] {
// ── Overview Tab ────────────────────────────────────────────────────────────── // ── 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 }) { 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 ( return (
<div className="h-full overflow-y-auto px-6 py-5 space-y-5"> <div className="h-full overflow-y-auto px-6 py-5 space-y-5">
{/* Stats row */} {/* Stats row */}
@@ -185,6 +242,11 @@ function OverviewTab({ agent, cronCount, sessionCount }: { agent: Agent; cronCou
<span>{agent.role || "—"}</span> <span>{agent.role || "—"}</span>
</div> </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 && ( {agent.lastActiveAt && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="w-20 shrink-0 text-muted-foreground text-xs">最近活跃</span> <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>
)} )}
</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> </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 ────────────────────────────────────────────────────────────── // ── Sessions Tab ──────────────────────────────────────────────────────────────
interface HistoryMsg { 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 { 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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
@@ -74,11 +74,11 @@ export function AgentInfoSheet({ agent, open, onOpenChange }: AgentInfoSheetProp
Promise.all([ Promise.all([
window.ipc.agentsFilesList({ agentId: agent.id }), window.ipc.agentsFilesList({ agentId: agent.id }),
window.ipc.cronList(), window.ipc.cronList(),
window.ipc.configGet(), window.ipc.openclawModelsGet(),
window.ipc.toolsCatalog({ agentId: agent.id }), window.ipc.toolsCatalog({ agentId: agent.id }),
window.ipc.agentsFilesGet({ agentId: agent.id, name: "MEMORY.md" }), window.ipc.agentsFilesGet({ agentId: agent.id, name: "MEMORY.md" }),
window.ipc.agentsMemoryList({ agentId: agent.id }), window.ipc.agentsMemoryList({ agentId: agent.id }),
]).then(([filesRes, cronRes, configRes, toolsRes, memoryRes, dailyRes]) => { ]).then(([filesRes, cronRes, modelsRes, toolsRes, memoryRes, dailyRes]) => {
if (filesRes.ok) { if (filesRes.ok) {
const r = filesRes.result as { workspace?: string; files?: FileEntry[] } const r = filesRes.result as { workspace?: string; files?: FileEntry[] }
setWorkspace(r.workspace ?? "") setWorkspace(r.workspace ?? "")
@@ -88,11 +88,12 @@ export function AgentInfoSheet({ agent, open, onOpenChange }: AgentInfoSheetProp
const list = cronRes.result as { jobs: CronJob[] } const list = cronRes.result as { jobs: CronJob[] }
setCronJobs((list.jobs ?? []).filter((j) => j.agentId === agent.id)) setCronJobs((list.jobs ?? []).filter((j) => j.agentId === agent.id))
} }
if (configRes.ok) { {
const cfg = configRes.result as { const result = modelsRes as {
parsed?: { models?: { defaults?: { primary?: string } } } 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) { if (toolsRes.ok) {
// tools.catalog may return { tools: ToolEntry[] } or { groups: { tools: ToolEntry[] }[] } // tools.catalog may return { tools: ToolEntry[] } or { groups: { tools: ToolEntry[] }[] }
@@ -157,27 +158,7 @@ export function AgentInfoSheet({ agent, open, onOpenChange }: AgentInfoSheetProp
</Section> </Section>
{/* 工具 */} {/* 工具 */}
<Section icon={<Wrench className="h-3.5 w-3.5" />} title={`工具 (${tools.length})`}> <CollapsibleToolsSection tools={tools} />
{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>
{/* 记忆 */} {/* 记忆 */}
<Section icon={<Brain className="h-3.5 w-3.5" />} title="记忆"> <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({ function Section({
icon, icon,
title, 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 { useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
@@ -38,10 +38,11 @@ import type { Conversation } from "@/types"
interface ChatHeaderProps { interface ChatHeaderProps {
conversation: Conversation conversation: Conversation
onToggleMembers?: () => void onToggleMembers?: () => void
onToggleWorkspace?: () => void
onAgentAvatarClick?: (agentId: string, agentName: string) => 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 { state, dispatch, refreshFleet } = useApp()
const { t } = useI18n() const { t } = useI18n()
const [renameOpen, setRenameOpen] = useState(false) 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> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground" />} 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 { TypingIndicator } from "./typing-indicator"
import { GroupMembersPanel } from "./group-members-panel" import { GroupMembersPanel } from "./group-members-panel"
import { PersonaPanel } from "@/components/persona/persona-panel" import { PersonaPanel } from "@/components/persona/persona-panel"
import { WorkspacePanel } from "./workspace-panel"
import { useI18n } from "@/i18n" import { useI18n } from "@/i18n"
import type { Message } from "@/types" import type { Message } from "@/types"
@@ -62,6 +63,7 @@ export function ChatWindow() {
const { t } = useI18n() const { t } = useI18n()
useAvatarVersion() // re-render when avatar changes useAvatarVersion() // re-render when avatar changes
const [showMembers, setShowMembers] = useState(false) const [showMembers, setShowMembers] = useState(false)
const [showWorkspace, setShowWorkspace] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const [personaPanelOpen, setPersonaPanelOpen] = useState(false) const [personaPanelOpen, setPersonaPanelOpen] = useState(false)
const [personaAgentId, setPersonaAgentId] = useState("") const [personaAgentId, setPersonaAgentId] = useState("")
@@ -232,6 +234,7 @@ export function ChatWindow() {
<ChatHeader <ChatHeader
conversation={conversation} conversation={conversation}
onToggleMembers={isGroup ? () => setShowMembers((p) => !p) : undefined} onToggleMembers={isGroup ? () => setShowMembers((p) => !p) : undefined}
onToggleWorkspace={!isGroup ? () => setShowWorkspace((p) => !p) : undefined}
onAgentAvatarClick={handleAgentAvatarClick} onAgentAvatarClick={handleAgentAvatarClick}
/> />
@@ -308,6 +311,14 @@ export function ChatWindow() {
/> />
)} )}
{!isGroup && conversation.members[0] && (
<WorkspacePanel
agentId={conversation.members[0]}
open={showWorkspace}
onOpenChange={setShowWorkspace}
/>
)}
<PersonaPanel <PersonaPanel
open={personaPanelOpen} open={personaPanelOpen}
onOpenChange={setPersonaPanelOpen} 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 { 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 { toast } from "sonner"
import { import {
AlertDialog, AlertDialog,
@@ -135,6 +135,11 @@ export function NavRail() {
label: "Agent 配置", label: "Agent 配置",
icon: <Bot className="h-5 w-5" />, icon: <Bot className="h-5 w-5" />,
}, },
{
id: "channels",
label: t("nav.channels"),
icon: <Radio className="h-5 w-5" />,
},
] ]
return ( return (
@@ -1,10 +1,23 @@
import { Minus, Square, X } from "lucide-react" import { Minus, Square, X } from "lucide-react"
import { useEffect, useState } from "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 定位在右上角,自身无背景,不破坏竖向色块连贯性 // 悬浮窗口控制按钮,fixed 定位在右上角,自身无背景,不破坏竖向色块连贯性
export function WindowControls() { export function WindowControls() {
const [isMaximized, setIsMaximized] = useState(false) const [isMaximized, setIsMaximized] = useState(false)
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false)
const { t } = useI18n()
useEffect(() => { useEffect(() => {
const unsubscribe = window.ipc.onMaximizedChanged((v) => setIsMaximized(v)) const unsubscribe = window.ipc.onMaximizedChanged((v) => setIsMaximized(v))
@@ -14,31 +27,53 @@ export function WindowControls() {
if (window.ipc.platform === "darwin") return null if (window.ipc.platform === "darwin") return null
return ( return (
<div <>
className="fixed top-0 right-0 flex items-center z-50" <div
style={{ height: "48px", WebkitAppRegion: "no-drag" } as React.CSSProperties} 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="最小化"
> >
<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"
<button onClick={() => window.ipc.windowMinimize()}
className="w-[46px] h-full flex items-center justify-center text-muted-foreground/50 hover:bg-accent hover:text-foreground transition-colors" title={t("windowControls.minimize")}
onClick={() => window.ipc.windowMaximize()} >
title={isMaximized ? "还原" : "最大化"} <Minus className="h-3 w-3" />
> </button>
<Square className="h-[11px] w-[11px]" /> <button
</button> className="w-[46px] h-full flex items-center justify-center text-muted-foreground/50 hover:bg-accent hover:text-foreground transition-colors"
<button onClick={() => window.ipc.windowMaximize()}
className="w-[46px] h-full flex items-center justify-center text-muted-foreground/50 hover:bg-red-500 hover:text-white transition-colors" title={isMaximized ? t("windowControls.restore") : t("windowControls.maximize")}
onClick={() => window.ipc.windowClose()} >
title="关闭" <Square className="h-[11px] w-[11px]" />
> </button>
<X className="h-3.5 w-3.5" /> <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"
</div> 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>
</>
) )
} }
+32
View File
@@ -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 }
+71 -2
View File
@@ -26,7 +26,8 @@
"openclaw": "OpenClaw", "openclaw": "OpenClaw",
"settings": "Settings", "settings": "Settings",
"changeMyAvatar": "Change my avatar", "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": { "topNav": {
"connection": { "connection": {
@@ -180,7 +181,8 @@
"voiceCall": "Voice call", "voiceCall": "Voice call",
"videoCall": "Video call", "videoCall": "Video call",
"searchMessages": "Search messages", "searchMessages": "Search messages",
"membersList": "Member list" "membersList": "Member list",
"workspace": "Workspace"
}, },
"menu": { "menu": {
"viewProfile": "View profile", "viewProfile": "View profile",
@@ -343,5 +345,72 @@
"upgradeTo": "New version", "upgradeTo": "New version",
"upgradeConfirm": "Upgrade Now", "upgradeConfirm": "Upgrade Now",
"upgradeSkip": "Skip for 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"
}
} }
} }
+71 -2
View File
@@ -26,7 +26,8 @@
"openclaw": "OpenClaw", "openclaw": "OpenClaw",
"settings": "设置", "settings": "设置",
"changeMyAvatar": "更换个人头像", "changeMyAvatar": "更换个人头像",
"changeMyAvatarDescription": "确认后将打开文件选择器,选择图片后会立即替换当前头像。" "changeMyAvatarDescription": "确认后将打开文件选择器,选择图片后会立即替换当前头像。",
"channels": "渠道"
}, },
"topNav": { "topNav": {
"connection": { "connection": {
@@ -180,7 +181,8 @@
"voiceCall": "语音通话", "voiceCall": "语音通话",
"videoCall": "视频通话", "videoCall": "视频通话",
"searchMessages": "搜索聊天记录", "searchMessages": "搜索聊天记录",
"membersList": "成员列表" "membersList": "成员列表",
"workspace": "工作空间"
}, },
"menu": { "menu": {
"viewProfile": "查看资料", "viewProfile": "查看资料",
@@ -343,5 +345,72 @@
"upgradeTo": "新版本", "upgradeTo": "新版本",
"upgradeConfirm": "立即升级", "upgradeConfirm": "立即升级",
"upgradeSkip": "暂时跳过" "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": "进度条"
}
} }
} }
+1 -1
View File
@@ -103,7 +103,7 @@ let _idCounter = 0
export const uniqueId = (prefix: string): string => export const uniqueId = (prefix: string): string =>
`${prefix}-${Date.now()}-${(++_idCounter).toString(36)}` `${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 => { export const parseViewFromHash = (): ViewType => {
if (typeof window === "undefined") return "chat" if (typeof window === "undefined") return "chat"
+1 -1
View File
@@ -85,4 +85,4 @@ export interface ChatAttachment {
fileName?: string 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"