From d5ca7c7dfae018a15dc3e51dcd3b398b9ca884c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=9F=E6=98=8E=E5=8E=9F?= Date: Mon, 16 Mar 2026 14:23:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=E3=80=81=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E9=9D=A2=E6=9D=BF=E3=80=81=E5=85=B3=E9=97=AD=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E5=BC=B9=E7=AA=97=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E4=BD=93=E8=AF=A6=E6=83=85=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增飞书 & Telegram 渠道配置页面(左侧导航 + 表单),支持读写 openclaw.json - 新增聊天工作区面板(WorkspacePanel),展示智能体文件列表 - 窗口关闭按钮增加二次确认弹窗,防止误关 - 修复智能体详情面板:模型信息改用 openclawModelsGet API,工具列表可折叠展示 - 智能体配置页 OverviewTab 同步修复模型 API 调用 - 窗口控件标题文字国际化 --- package.json | 1 + pnpm-lock.yaml | 31 + src/main/ipc/channels.ts | 31 + src/main/ipc/index.ts | 2 + src/preload/index.ts | 5 + src/renderer/src/App.tsx | 2 + .../agent-config/agent-config-view.tsx | 156 +++++ .../src/components/channels/channels-view.tsx | 597 ++++++++++++++++++ .../src/components/chat/agent-info-sheet.tsx | 85 ++- .../src/components/chat/chat-header.tsx | 13 +- .../src/components/chat/chat-window.tsx | 11 + .../src/components/chat/workspace-panel.tsx | 386 +++++++++++ .../src/components/layout/nav-rail.tsx | 7 +- .../src/components/layout/window-controls.tsx | 85 ++- src/renderer/src/components/ui/switch.tsx | 32 + src/renderer/src/i18n/locales/en.json | 73 ++- src/renderer/src/i18n/locales/zh-CN.json | 73 ++- src/renderer/src/store/app-utils.ts | 2 +- src/renderer/src/types.ts | 2 +- 19 files changed, 1532 insertions(+), 62 deletions(-) create mode 100644 src/main/ipc/channels.ts create mode 100644 src/renderer/src/components/channels/channels-view.tsx create mode 100644 src/renderer/src/components/chat/workspace-panel.tsx create mode 100644 src/renderer/src/components/ui/switch.tsx diff --git a/package.json b/package.json index 7e02bc4..454934d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 724b4c8..719927b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts new file mode 100644 index 0000000..65350e5 --- /dev/null +++ b/src/main/ipc/channels.ts @@ -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 }) => { + 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) } + } + }, + ) +} diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 65f70fb..dff4e13 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -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) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 743a35a..0ac8a66 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 }) => + ipcRenderer.invoke('openclaw:channels:set', params), + // ── File path (Electron 32+ replaces deprecated file.path) ─────────────── getFilePath: (file: File) => webUtils.getPathForFile(file), } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8d7993c..0278ffa 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 case 'skills': return case 'agent-config': return + case 'channels': return default: return } } diff --git a/src/renderer/src/components/agent-config/agent-config-view.tsx b/src/renderer/src/components/agent-config/agent-config-view.tsx index 5e0cfc8..d9a01c5 100644 --- a/src/renderer/src/components/agent-config/agent-config-view.tsx +++ b/src/renderer/src/components/agent-config/agent-config-view.tsx @@ -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([]) + const [defaultModel, setDefaultModel] = useState("") + const [tools, setTools] = useState([]) + 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 + 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 (
{/* Stats row */} @@ -185,6 +242,11 @@ function OverviewTab({ agent, cronCount, sessionCount }: { agent: Agent; cronCou {agent.role || "—"}
+
+ 模型 + {overviewLoading ? "…" : (defaultModel || "—")} +
+ {agent.lastActiveAt && (
最近活跃 @@ -192,10 +254,104 @@ function OverviewTab({ agent, cronCount, sessionCount }: { agent: Agent; cronCou
)} + + {overviewLoading ? ( +
+ 加载中... +
+ ) : ( + <> + {/* 工作空间 */} + +
+
+ +

+ 工作空间 +

+
+ {workspace && ( +

+ {workspace} +

+ )} + {files.length > 0 ? ( +
+ {files.map((file) => ( +
+ {file.name} + {file.missing ? ( + ! + ) : file.size != null ? ( + {formatSize(file.size)} + ) : null} +
+ ))} +
+ ) : ( +

暂无文件

+ )} +
+ + {/* 工具 */} + {tools.length > 0 && ( + <> + +
+
+ +

+ 工具 ({tools.length}) +

+
+
+ {tools.map((tool) => ( +
+ {tool.name} + {tool.description && ( + {tool.description} + )} +
+ ))} +
+
+ + )} + + {/* 记忆摘要 */} + {(memorySummary || dailyMemoryCount > 0) && ( + <> + +
+
+ +

+ 记忆 +

+
+ {memorySummary && ( +

+ {memorySummary}{memorySummary.length >= 200 ? "…" : ""} +

+ )} + {dailyMemoryCount > 0 && ( +

{dailyMemoryCount} 条每日记忆

+ )} +
+ + )} + + )} ) } +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 { diff --git a/src/renderer/src/components/channels/channels-view.tsx b/src/renderer/src/components/channels/channels-view.tsx new file mode 100644 index 0000000..bbc0cb2 --- /dev/null +++ b/src/renderer/src/components/channels/channels-view.tsx @@ -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 }) => ( + + + +) + +const CHANNELS: ChannelMeta[] = [ + { id: "feishu", icon: , nameKey: "channels.feishu.name", descKey: "channels.feishu.desc" }, + { id: "telegram", icon: , nameKey: "channels.telegram.name", descKey: "channels.telegram.desc" }, +] + +type FeishuConfig = { + enabled?: boolean + domain?: string + connectionMode?: string + dmPolicy?: string + groupPolicy?: string + accounts?: Record +} + +type TelegramConfig = { + enabled?: boolean + botToken?: string + dmPolicy?: string + groupPolicy?: string + streaming?: string +} + +// ── Main View ──────────────────────────────────────────────────────────────── + +export function ChannelsView() { + const { t } = useI18n() + const [selected, setSelected] = useState("feishu") + const [allChannels, setAllChannels] = useState>>({}) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + // Local form state + const [feishuForm, setFeishuForm] = useState({}) + const [telegramForm, setTelegramForm] = useState({}) + + useEffect(() => { + let cancelled = false + setLoading(true) + + window.ipc.channelsGet().then((res) => { + if (cancelled) return + const r = res as { ok: boolean; channels?: Record> } + 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 + 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 + 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) ?? {}), + 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 + 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 ( +
+ {/* Page Header */} +
+
+
+ +
+
+

{t("channels.title")}

+

{t("channels.description")}

+
+
+
+ + {/* Content */} + {loading ? ( +
+ + {t("channels.loading")} +
+ ) : ( +
+ {/* Left: Channel list */} +
+ {CHANNELS.map((ch) => ( + + ))} +
+ + {/* Right: Config form */} +
+ {selected === "feishu" ? ( + + ) : ( + + )} +
+
+ )} +
+ ) +} + +// ── Shared form components ─────────────────────────────────────────────────── + +function FormSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +function FormField({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} + +function PasswordInput({ + value, + onChange, + placeholder, +}: { + value: string + onChange: (v: string) => void + placeholder?: string +}) { + const [show, setShow] = useState(false) + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} + className="h-8 text-sm font-mono pr-9" + /> + +
+ ) +} + +function PolicySelect({ + value, + onChange, + t, +}: { + value: string + onChange: (v: string) => void + t: (key: string) => string +}) { + return ( + + ) +} + +// ── 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) => { + onChange({ + ...value, + accounts: { + ...value.accounts, + main: { ...main, ...patch }, + }, + }) + } + + return ( +
+ {/* Header with switch */} +
+
+ +

{t("channels.feishu.name")}

+ {main.appId && ( + + {value.enabled !== false ? t("channels.enabled") : t("channels.disabled")} + + )} +
+ onChange({ ...value, enabled: checked })} + /> +
+ + {/* Required fields */} + + + updateMain({ appId: e.target.value })} + placeholder="cli_a1b2c3d4e5f6" + className="h-8 text-sm font-mono" + /> + + + + updateMain({ appSecret: v })} + placeholder={t("channels.feishu.appSecretPlaceholder")} + /> + + + +

+ {t("channels.feishu.setupHint")} +

+ + {/* Optional fields */} + + + updateMain({ botName: e.target.value })} + placeholder={t("channels.feishu.botNamePlaceholder")} + className="h-8 text-sm" + /> + + + + + + + + + + + + onChange({ ...value, dmPolicy: v })} + t={t} + /> + + + + onChange({ ...value, groupPolicy: v })} + t={t} + /> + + + + {/* Save button */} +
+ + {t("channels.restartHint")} +
+
+ ) +} + +// ── Telegram Config Form ───────────────────────────────────────────────────── + +function TelegramConfigForm({ + value, + onChange, + saving, + onSave, +}: { + value: TelegramConfig + onChange: (v: TelegramConfig) => void + saving: boolean + onSave: () => void +}) { + const { t } = useI18n() + + return ( +
+ {/* Header with switch */} +
+
+ +

{t("channels.telegram.name")}

+ {value.botToken && ( + + {value.enabled !== false ? t("channels.enabled") : t("channels.disabled")} + + )} +
+ onChange({ ...value, enabled: checked })} + /> +
+ + {/* Required fields */} + + + onChange({ ...value, botToken: v })} + placeholder="123456789:ABCDefGHiJKlmnoPQRstuvwXYZ" + /> + + + +

+ {t("channels.telegram.setupHint")} +

+ + {/* Optional fields */} + + + onChange({ ...value, dmPolicy: v })} + t={t} + /> + + + + onChange({ ...value, groupPolicy: v })} + t={t} + /> + + + + + + + + {/* Save button */} +
+ + {t("channels.restartHint")} +
+
+ ) +} diff --git a/src/renderer/src/components/chat/agent-info-sheet.tsx b/src/renderer/src/components/chat/agent-info-sheet.tsx index 508cd79..375cdb4 100644 --- a/src/renderer/src/components/chat/agent-info-sheet.tsx +++ b/src/renderer/src/components/chat/agent-info-sheet.tsx @@ -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 + 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 {/* 工具 */} -
} title={`工具 (${tools.length})`}> - {tools.length > 0 ? ( -
- {tools.map((tool) => ( - - {tool.name} - {tool.source === "plugin" && tool.pluginId && ( - plug - )} - - ))} -
- ) : ( -

暂无工具数据

- )} -
+ {/* 记忆 */}
} 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 ( +
+ + {tools.length > 0 ? ( +
+ {(expanded ? tools : tools.slice(0, previewCount)).map((tool) => ( +
+ {tool.name} + {tool.description && ( + {tool.description} + )} +
+ ))} + {!expanded && tools.length > previewCount && ( + + )} +
+ ) : ( +

暂无工具数据

+ )} +
+ ) +} + function Section({ icon, title, diff --git a/src/renderer/src/components/chat/chat-header.tsx b/src/renderer/src/components/chat/chat-header.tsx index 9e6126a..5a1f0c6 100644 --- a/src/renderer/src/components/chat/chat-header.tsx +++ b/src/renderer/src/components/chat/chat-header.tsx @@ -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 && ( + } + tooltip={t("header.tooltips.workspace")} + onClick={onToggleWorkspace} + /> + )} + } diff --git a/src/renderer/src/components/chat/chat-window.tsx b/src/renderer/src/components/chat/chat-window.tsx index 6817748..38c90b3 100644 --- a/src/renderer/src/components/chat/chat-window.tsx +++ b/src/renderer/src/components/chat/chat-window.tsx @@ -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(null) const [personaPanelOpen, setPersonaPanelOpen] = useState(false) const [personaAgentId, setPersonaAgentId] = useState("") @@ -232,6 +234,7 @@ export function ChatWindow() { setShowMembers((p) => !p) : undefined} + onToggleWorkspace={!isGroup ? () => setShowWorkspace((p) => !p) : undefined} onAgentAvatarClick={handleAgentAvatarClick} /> @@ -308,6 +311,14 @@ export function ChatWindow() { /> )} + {!isGroup && conversation.members[0] && ( + + )} + void +} + +export function WorkspacePanel({ agentId, open, onOpenChange }: WorkspacePanelProps) { + const { t } = useI18n() + + // data + const [workspace, setWorkspace] = useState("") + const [bootstrapFiles, setBootstrapFiles] = useState([]) + const [memoryFiles, setMemoryFiles] = useState([]) + const [loadingList, setLoadingList] = useState(false) + + // tree state + const [memoryExpanded, setMemoryExpanded] = useState(false) + const [selected, setSelected] = useState(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 ( +
+
+ {/* Header — Windows 下右侧留出窗口控件的空间,避免关闭按钮和系统按钮重叠 */} +
+
+ + {t("workspace.title")} +
+ +
+ + {/* Workspace path */} + {workspace && ( +
+

+ {workspace} +

+
+ )} + + {loadingList ? ( +
+ + {t("workspace.loading")} +
+ ) : ( +
+ {/* File list — scrollable, max ~40% height */} + +
+ {/* Bootstrap files */} + {bootstrapFiles.map((file) => ( + + handleSelectFile({ source: "bootstrap", name: file.name }) + } + /> + ))} + + {/* Memory folder */} + {memoryFiles.length > 0 && ( + <> + + {memoryExpanded && + memoryFiles.map((file) => ( + + handleSelectFile({ source: "memory", name: file.name }) + } + /> + ))} + + )} +
+
+ + {/* Content preview — fills remaining space */} + {selected ? ( +
+ {/* File header */} +
+
+ + + {isMemoryFile ? `memory/${selectedName}` : selectedName} + +
+ {!isMemoryFile && ( + + )} + {isMemoryFile && ( + + {t("workspace.readonly")} + + )} +
+ + {/* Content */} + {loadingFile ? ( +
+ +
+ ) : ( +