diff --git a/desktop/scripts/e2e-install-center-agent-browser.sh b/desktop/scripts/e2e-install-center-agent-browser.sh new file mode 100755 index 00000000..b0a21e5a --- /dev/null +++ b/desktop/scripts/e2e-install-center-agent-browser.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DESKTOP_DIR="$ROOT_DIR/desktop" + +if ! command -v agent-browser >/dev/null 2>&1; then + echo "agent-browser is required but was not found in PATH" >&2 + exit 1 +fi + +PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')" +API_PORT="${PORTS%% *}" +WEB_PORT="${PORTS##* }" +BASE_URL="http://127.0.0.1:${API_PORT}" +WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}" + +RUN_ID="$(date +%s)-$RANDOM" +SESSION_NAME="cc-haha-install-e2e-${RUN_ID}" +ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-install-e2e-${RUN_ID}-XXXX")" +SERVER_LOG="${ARTIFACT_DIR}/server.log" +WEB_LOG="${ARTIFACT_DIR}/web.log" + +if [[ "${CC_HAHA_E2E_USE_REAL_CONFIG:-1}" == "1" ]]; then + CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +else + CONFIG_DIR="${CLAUDE_CONFIG_DIR:-${ARTIFACT_DIR}/claude-config}" + mkdir -p "${CONFIG_DIR}" +fi + +cleanup() { + local exit_code=$? + if [[ -n "${SERVER_PID:-}" ]]; then + kill "${SERVER_PID}" >/dev/null 2>&1 || true + wait "${SERVER_PID}" >/dev/null 2>&1 || true + fi + if [[ -n "${WEB_PID:-}" ]]; then + kill "${WEB_PID}" >/dev/null 2>&1 || true + wait "${WEB_PID}" >/dev/null 2>&1 || true + fi + agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true + if [[ $exit_code -ne 0 ]]; then + echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2 + else + rm -rf "${ARTIFACT_DIR}" + fi +} +trap cleanup EXIT + +echo "Starting isolated backend on ${BASE_URL}" +( + cd "${ROOT_DIR}" + CLAUDE_CONFIG_DIR="${CONFIG_DIR}" SERVER_PORT="${API_PORT}" bun run src/server/index.ts --host 127.0.0.1 --port "${API_PORT}" +) >"${SERVER_LOG}" 2>&1 & +SERVER_PID=$! + +echo "Starting web UI on http://127.0.0.1:${WEB_PORT}" +( + cd "${DESKTOP_DIR}" + bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}" +) >"${WEB_LOG}" 2>&1 & +WEB_PID=$! + +wait_for_http() { + local url="$1" + for _ in $(seq 1 120); do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + echo "Timed out waiting for ${url}" >&2 + return 1 +} + +browser_body() { + agent-browser --session "${SESSION_NAME}" get text body +} + +wait_for_body_contains() { + local needle="$1" + local attempts="${2:-180}" + for _ in $(seq 1 "${attempts}"); do + if browser_body | grep -Fq "${needle}"; then + return 0 + fi + sleep 2 + done + echo "Timed out waiting for page text: ${needle}" >&2 + return 1 +} + +wait_for_body_contains_any() { + local attempts="$1" + shift + + for _ in $(seq 1 "${attempts}"); do + local body + body="$(browser_body)" + for needle in "$@"; do + if grep -Fq "${needle}" <<<"${body}"; then + return 0 + fi + done + sleep 2 + done + + echo "Timed out waiting for any expected page text: $*" >&2 + return 1 +} + +wait_for_path() { + local path="$1" + local attempts="${2:-180}" + for _ in $(seq 1 "${attempts}"); do + if [[ -e "${path}" ]]; then + return 0 + fi + sleep 2 + done + echo "Timed out waiting for path: ${path}" >&2 + return 1 +} + +AB="agent-browser --session ${SESSION_NAME}" +TELEGRAM_PROMPT="请根据 https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/telegram 安装 Telegram 官方插件。如果 URL 对应官方 external_plugins,请直接识别 plugin id 并完成用户级安装,然后告诉我结果。" +SKILL_PROMPT="请根据 https://www.aitmpl.com/component/skill/creative-design/ui-ux-pro-max 这个 skill 页面安装 ui-ux-pro-max。如果页面里有官方安装命令,请直接识别并执行,然后告诉我结果。" +PLUGIN_DIR="${CONFIG_DIR}/plugins/cache/claude-plugins-official/telegram" +SKILL_DIR="${CONFIG_DIR}/skills/ui-ux-pro-max" + +wait_for_http "${BASE_URL}/health" +wait_for_http "http://127.0.0.1:${WEB_PORT}" + +${AB} open "${WEB_URL}" +${AB} click 'button:has-text("Settings")' +${AB} click 'button:has-text("Install")' +${AB} screenshot "${ARTIFACT_DIR}/01-install-center.png" >/dev/null + +run_install_prompt() { + local prompt="$1" + ${AB} click 'button:has-text("New install chat")' + ${AB} fill 'textarea' "${prompt}" + ${AB} click 'button:has-text("Send request")' +} + +echo "Running plugin install flow" +run_install_prompt "${TELEGRAM_PROMPT}" +wait_for_body_contains 'telegram@claude-plugins-official' +wait_for_path "${PLUGIN_DIR}" +${AB} click 'button:has-text("Open Plugins")' +wait_for_body_contains 'Enabled1' +wait_for_body_contains 'telegram Enabled' +${AB} screenshot "${ARTIFACT_DIR}/02-plugin-installed.png" >/dev/null + +echo "Running skill install flow" +${AB} click 'button:has-text("Install")' +run_install_prompt "${SKILL_PROMPT}" +wait_for_body_contains 'claude-code-templates@latest' +wait_for_path "${SKILL_DIR}" +${AB} click 'button:has-text("Open Skills")' +wait_for_body_contains 'Installed Skills' +wait_for_body_contains 'ui-ux-pro-max' +${AB} screenshot "${ARTIFACT_DIR}/03-skill-installed.png" >/dev/null + +echo "Install Center E2E passed" +echo "Config dir: ${CONFIG_DIR}" +echo "API port: ${API_PORT}" +echo "Web port: ${WEB_PORT}" diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 84fa723d..753c92f2 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -114,13 +114,22 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel { return { renderItems: items, toolResultMap, childToolCallsByParent } } -export function MessageList() { +type MessageListProps = { + sessionId?: string | null +} + +export function MessageList({ sessionId }: MessageListProps = {}) { const activeTabId = useTabStore((s) => s.activeTabId) - const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) + const resolvedSessionId = sessionId ?? activeTabId + const sessionState = useChatStore((s) => + resolvedSessionId ? s.sessions[resolvedSessionId] : undefined, + ) const stopGeneration = useChatStore((s) => s.stopGeneration) const reloadHistory = useChatStore((s) => s.reloadHistory) const queueComposerPrefill = useChatStore((s) => s.queueComposerPrefill) - const isMemberSession = useTeamStore((s) => activeTabId ? Boolean(s.getMemberBySessionId(activeTabId)) : false) + const isMemberSession = useTeamStore((s) => + resolvedSessionId ? Boolean(s.getMemberBySessionId(resolvedSessionId)) : false, + ) const addToast = useUIStore((s) => s.addToast) const messages = sessionState?.messages ?? [] const chatState = sessionState?.chatState ?? 'idle' @@ -144,7 +153,7 @@ export function MessageList() { }, [messages.length, streamingText]) useEffect(() => { - if (!activeTabId || !rewindTarget) return + if (!resolvedSessionId || !rewindTarget) return let cancelled = false setIsLoadingPreview(true) @@ -152,7 +161,7 @@ export function MessageList() { setRewindError(null) void sessionsApi - .rewind(activeTabId, { + .rewind(resolvedSessionId, { userMessageIndex: rewindTarget.userMessageIndex, dryRun: true, }) @@ -182,7 +191,7 @@ export function MessageList() { return () => { cancelled = true } - }, [activeTabId, rewindTarget]) + }, [resolvedSessionId, rewindTarget]) const { toolResultMap, childToolCallsByParent, renderItems } = useMemo( () => buildRenderModel(messages), @@ -198,22 +207,22 @@ export function MessageList() { }, [isExecutingRewind]) const handleConfirmRewind = useCallback(async () => { - if (!activeTabId || !rewindTarget || isExecutingRewind) return + if (!resolvedSessionId || !rewindTarget || isExecutingRewind) return setIsExecutingRewind(true) setRewindError(null) try { if (chatState !== 'idle') { - stopGeneration(activeTabId) + stopGeneration(resolvedSessionId) } - const result = await sessionsApi.rewind(activeTabId, { + const result = await sessionsApi.rewind(resolvedSessionId, { userMessageIndex: rewindTarget.userMessageIndex, }) - await reloadHistory(activeTabId) - queueComposerPrefill(activeTabId, { + await reloadHistory(resolvedSessionId) + queueComposerPrefill(resolvedSessionId, { text: rewindTarget.content, attachments: rewindTarget.attachments, }) @@ -245,12 +254,12 @@ export function MessageList() { setIsExecutingRewind(false) } }, [ - activeTabId, addToast, chatState, isExecutingRewind, queueComposerPrefill, reloadHistory, + resolvedSessionId, rewindTarget, stopGeneration, t, diff --git a/desktop/src/components/settings/InstallCenter.tsx b/desktop/src/components/settings/InstallCenter.tsx new file mode 100644 index 00000000..9a81aaa3 --- /dev/null +++ b/desktop/src/components/settings/InstallCenter.tsx @@ -0,0 +1,433 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { sessionsApi } from '../../api/sessions' +import { useChatStore } from '../../stores/chatStore' +import { useMcpStore } from '../../stores/mcpStore' +import { usePluginStore } from '../../stores/pluginStore' +import { useSessionStore } from '../../stores/sessionStore' +import { useSkillStore } from '../../stores/skillStore' +import { useTranslation } from '../../i18n' +import { MessageList } from '../chat/MessageList' +import { ComputerUsePermissionModal } from '../chat/ComputerUsePermissionModal' +import { Button } from '../shared/Button' +import { Textarea } from '../shared/Textarea' +import { DirectoryPicker } from '../shared/DirectoryPicker' +import { useUIStore } from '../../stores/uiStore' +import { buildInstallerPrompt } from '../../lib/installAssistantPrompt' + +const INSTALLER_SESSION_KEY = 'cc-haha-installer-session-id' +const INSTALLER_CONTEXT_KEY = 'cc-haha-installer-context-dir' + +const EXAMPLE_PROMPTS = [ + '安装 plugin:skill-creator@claude-plugins-official,并应用到当前桌面端', + '添加一个 MCP:name=linear,url=https://example.com/mcp,优先用户级', + '帮我把一个 GitHub 仓库里的 skill 装到 ~/.claude/skills,并告诉我装到了哪里', +] + +function readStoredValue(key: string) { + try { + return localStorage.getItem(key) || '' + } catch { + return '' + } +} + +function writeStoredValue(key: string, value: string) { + try { + if (value) { + localStorage.setItem(key, value) + } else { + localStorage.removeItem(key) + } + } catch { + // noop + } +} + +export function InstallCenter() { + const t = useTranslation() + const sessions = useSessionStore((s) => s.sessions) + const fetchSessions = useSessionStore((s) => s.fetchSessions) + const connectToSession = useChatStore((s) => s.connectToSession) + const disconnectSession = useChatStore((s) => s.disconnectSession) + const sendMessage = useChatStore((s) => s.sendMessage) + const stopGeneration = useChatStore((s) => s.stopGeneration) + const fetchPlugins = usePluginStore((s) => s.fetchPlugins) + const reloadPlugins = usePluginStore((s) => s.reloadPlugins) + const fetchSkills = useSkillStore((s) => s.fetchSkills) + const fetchServers = useMcpStore((s) => s.fetchServers) + const addToast = useUIStore((s) => s.addToast) + const setPendingSettingsTab = useUIStore((s) => s.setPendingSettingsTab) + + const [sessionId, setSessionId] = useState(() => { + const stored = readStoredValue(INSTALLER_SESSION_KEY) + return stored || null + }) + const [contextDir, setContextDir] = useState(() => readStoredValue(INSTALLER_CONTEXT_KEY)) + const [draft, setDraft] = useState('') + const [isCreating, setIsCreating] = useState(false) + const createPromiseRef = useRef | null>(null) + const previousChatStateRef = useRef<'idle' | 'thinking' | 'tool_executing' | 'streaming' | 'permission_pending'>('idle') + + const installerSession = useMemo( + () => sessions.find((session) => session.id === sessionId) || null, + [sessionId, sessions], + ) + const sessionState = useChatStore((s) => + sessionId ? s.sessions[sessionId] : undefined, + ) + const chatState = sessionState?.chatState ?? 'idle' + const pendingComputerUsePermission = + sessionState?.pendingComputerUsePermission?.request ?? null + const isBusy = isCreating || chatState !== 'idle' + + useEffect(() => { + if (!sessionId) return + connectToSession(sessionId) + return () => { + disconnectSession(sessionId) + } + }, [connectToSession, disconnectSession, sessionId]) + + useEffect(() => { + writeStoredValue(INSTALLER_CONTEXT_KEY, contextDir.trim()) + }, [contextDir]) + + useEffect(() => { + if (!sessionId) return + const previousState = previousChatStateRef.current + previousChatStateRef.current = chatState + + if (previousState === 'idle' || chatState !== 'idle') return + + const cwd = installerSession?.workDir || undefined + + void (async () => { + await reloadPlugins(cwd).catch(() => null) + await Promise.allSettled([ + fetchPlugins(cwd), + fetchSkills(cwd), + fetchServers(cwd ? [cwd] : undefined, cwd), + ]) + })() + }, [ + chatState, + fetchPlugins, + fetchServers, + fetchSkills, + installerSession?.workDir, + reloadPlugins, + sessionId, + ]) + + const ensureInstallerSession = async () => { + if (sessionId && installerSession) { + return sessionId + } + + if (createPromiseRef.current) { + return createPromiseRef.current + } + + setIsCreating(true) + createPromiseRef.current = (async () => { + const { sessionId: createdSessionId } = await sessionsApi.create( + contextDir.trim() || undefined, + ) + await sessionsApi.rename( + createdSessionId, + t('settings.install.sessionTitle'), + ) + writeStoredValue(INSTALLER_SESSION_KEY, createdSessionId) + setSessionId(createdSessionId) + await fetchSessions() + connectToSession(createdSessionId) + return createdSessionId + })() + + try { + return await createPromiseRef.current + } finally { + createPromiseRef.current = null + setIsCreating(false) + } + } + + const handleSubmit = async () => { + const request = draft.trim() + if (!request || isBusy) return + + try { + const ensuredSessionId = await ensureInstallerSession() + sendMessage( + ensuredSessionId, + buildInstallerPrompt(request), + undefined, + { displayContent: request }, + ) + setDraft('') + } catch (error) { + addToast({ + type: 'error', + message: + error instanceof Error + ? error.message + : t('settings.install.createFailed'), + }) + } + } + + const handleRefresh = async () => { + const cwd = installerSession?.workDir || undefined + await Promise.all([ + fetchPlugins(cwd), + fetchSkills(cwd), + fetchServers(cwd ? [cwd] : undefined, cwd), + ]) + addToast({ + type: 'success', + message: t('settings.install.refreshDone'), + }) + } + + const startFreshConversation = async () => { + if (sessionId) { + disconnectSession(sessionId) + } + writeStoredValue(INSTALLER_SESSION_KEY, '') + setSessionId(null) + previousChatStateRef.current = 'idle' + addToast({ + type: 'info', + message: t('settings.install.newConversationReady'), + }) + } + + return ( +
+
+
+
+
+ {t('settings.install.eyebrow')} +
+
+ + download + +

+ {t('settings.install.title')} +

+
+

+ {t('settings.install.description')} +

+
+ +
+ + + + +
+
+
+ +
+
+
+

+ {t('settings.install.composeTitle')} +

+

+ {t('settings.install.composeHint')} +

+
+
+ + +
+
+ +
+
+