mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Reduce extension setup friction inside desktop settings
The desktop app already had solid session streaming, permission, and tool-rendering flows, but extension setup still forced users into manual forms or external shell work. This change adds an Install Center in Settings that reuses the session chat pipeline for natural-language installs, adds installer-specific guidance for plugin and skill URLs, and includes an agent-browser E2E script for real UI validation. Constraint: Must reuse the existing session/chat execution path instead of introducing a second install runtime Constraint: Plugin installs need real CLI commands while skill installs may come from published install commands on third-party pages Rejected: Separate terminal-only install surface | duplicates session UX and weakens permission/tool visibility Rejected: Pure form-based installer expansion | too much friction for plugin, MCP, and skill onboarding Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep installer prompts aligned with the actual CLI install surfaces; do not let the installer fall back to slash-command syntax inside Bash Tested: cd desktop && bun run lint Tested: Real UI automation via agent-browser for Telegram plugin install flow through Settings > Install, verified Plugins page shows telegram enabled Tested: Real UI automation via agent-browser for ui-ux-pro-max skill install flow through Settings > Install, verified ~/.claude/skills/ui-ux-pro-max and Skills page visibility Not-tested: Full e2e-install-center-agent-browser.sh script as a single uninterrupted green run after the latest stability tweaks
This commit is contained in:
parent
6c02904e58
commit
28f36da0fd
168
desktop/scripts/e2e-install-center-agent-browser.sh
Executable file
168
desktop/scripts/e2e-install-center-agent-browser.sh
Executable file
@ -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}"
|
||||
@ -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,
|
||||
|
||||
433
desktop/src/components/settings/InstallCenter.tsx
Normal file
433
desktop/src/components/settings/InstallCenter.tsx
Normal file
@ -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<string | null>(() => {
|
||||
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<Promise<string> | 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 (
|
||||
<div className="w-full min-w-0">
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
|
||||
<div className="grid gap-4 px-5 py-5 xl:grid-cols-[minmax(0,1.5fr)_minmax(280px,1fr)] xl:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
|
||||
{t('settings.install.eyebrow')}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
|
||||
download
|
||||
</span>
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{t('settings.install.title')}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl">
|
||||
{t('settings.install.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:grid-cols-2">
|
||||
<SummaryPill
|
||||
label={t('settings.install.targets.plugins')}
|
||||
icon="extension"
|
||||
/>
|
||||
<SummaryPill
|
||||
label={t('settings.install.targets.mcp')}
|
||||
icon="hub"
|
||||
/>
|
||||
<SummaryPill
|
||||
label={t('settings.install.targets.skills')}
|
||||
icon="auto_awesome"
|
||||
/>
|
||||
<SummaryPill
|
||||
label={installerSession?.workDir || t('settings.install.contextAuto')}
|
||||
icon="folder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{t('settings.install.composeTitle')}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||||
{t('settings.install.composeHint')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void handleRefresh()}
|
||||
>
|
||||
{t('settings.install.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void startFreshConversation()}
|
||||
>
|
||||
{t('settings.install.newConversation')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="min-w-0">
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void handleSubmit()
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.install.placeholder')}
|
||||
className="min-h-[140px]"
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{EXAMPLE_PROMPTS.map((example) => (
|
||||
<button
|
||||
key={example}
|
||||
type="button"
|
||||
onClick={() => setDraft(example)}
|
||||
className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
{example}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{chatState !== 'idle' && sessionId ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => stopGeneration(sessionId)}
|
||||
>
|
||||
{t('settings.install.stop')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleSubmit()}
|
||||
loading={isCreating}
|
||||
disabled={!draft.trim() || isBusy}
|
||||
icon={
|
||||
!isCreating ? (
|
||||
<span className="material-symbols-outlined text-[16px]">send</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t('settings.install.send')}
|
||||
</Button>
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{installerSession?.workDir
|
||||
? t('settings.install.contextUsing', {
|
||||
path: installerSession.workDir,
|
||||
})
|
||||
: t('settings.install.contextDefault')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
|
||||
{t('settings.install.contextTitle')}
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-[var(--color-text-tertiary)]">
|
||||
{t('settings.install.contextHint')}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<DirectoryPicker value={contextDir} onChange={setContextDir} />
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPendingSettingsTab('plugins')}
|
||||
>
|
||||
{t('settings.install.goPlugins')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPendingSettingsTab('mcp')}
|
||||
>
|
||||
{t('settings.install.goMcp')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPendingSettingsTab('skills')}
|
||||
>
|
||||
{t('settings.install.goSkills')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{t('settings.install.sessionTitle')}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||||
{sessionId
|
||||
? t('settings.install.sessionHint')
|
||||
: t('settings.install.sessionEmpty')}
|
||||
</p>
|
||||
</div>
|
||||
{sessionId ? (
|
||||
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
|
||||
{chatState}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-h-[420px] bg-[var(--color-surface-container-lowest)]">
|
||||
{sessionId ? (
|
||||
<>
|
||||
<MessageList sessionId={sessionId} />
|
||||
<ComputerUsePermissionModal
|
||||
sessionId={sessionId}
|
||||
request={pendingComputerUsePermission}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-[420px] flex-col items-center justify-center px-6 text-center">
|
||||
<span className="material-symbols-outlined text-[36px] text-[var(--color-text-tertiary)] mb-3">
|
||||
forum
|
||||
</span>
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">
|
||||
{t('settings.install.sessionEmpty')}
|
||||
</p>
|
||||
<p className="mt-2 max-w-md text-xs leading-6 text-[var(--color-text-tertiary)]">
|
||||
{t('settings.install.sessionEmptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryPill({
|
||||
label,
|
||||
icon,
|
||||
}: {
|
||||
label: string
|
||||
icon: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] min-w-0">
|
||||
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -50,6 +50,7 @@ export const en = {
|
||||
'settings.tab.providers': 'Providers',
|
||||
'settings.tab.permissions': 'Permissions',
|
||||
'settings.tab.general': 'General',
|
||||
'settings.tab.install': 'Install',
|
||||
'settings.tab.skills': 'Skills',
|
||||
'settings.tab.mcp': 'MCP',
|
||||
'settings.tab.plugins': 'Plugins',
|
||||
@ -287,6 +288,36 @@ export const en = {
|
||||
'settings.agents.source.flagSettings': 'CLI arg',
|
||||
'settings.agents.source.built-in': 'Built-in',
|
||||
|
||||
// Settings > Install Center
|
||||
'settings.install.eyebrow': 'AI Installer',
|
||||
'settings.install.title': 'Install Center',
|
||||
'settings.install.description': 'Describe the plugin, MCP server, or skill you want. The desktop app will reuse a dedicated session to execute the installation flow and then sync results back to the existing management pages.',
|
||||
'settings.install.targets.plugins': 'Plugins',
|
||||
'settings.install.targets.mcp': 'MCP',
|
||||
'settings.install.targets.skills': 'Skills',
|
||||
'settings.install.contextAuto': 'Default context',
|
||||
'settings.install.composeTitle': 'Natural language install',
|
||||
'settings.install.composeHint': 'Describe what to install, where it comes from, and whether it should land in user or project scope.',
|
||||
'settings.install.placeholder': 'Example: install plugin skill-creator@claude-plugins-official and apply it to the current desktop runtime. Or: add a user-scope MCP at https://example.com/mcp.',
|
||||
'settings.install.send': 'Send request',
|
||||
'settings.install.stop': 'Stop',
|
||||
'settings.install.refresh': 'Refresh state',
|
||||
'settings.install.newConversation': 'New install chat',
|
||||
'settings.install.contextDefault': 'If no directory is selected, the installer session starts from your home directory.',
|
||||
'settings.install.contextUsing': 'Installer session directory: {path}',
|
||||
'settings.install.contextTitle': 'Execution directory',
|
||||
'settings.install.contextHint': 'Most user-scope installs do not need a project path. If you need project skills or local/project MCP config, pick a directory here and then start a new install chat.',
|
||||
'settings.install.goPlugins': 'Open Plugins',
|
||||
'settings.install.goMcp': 'Open MCP',
|
||||
'settings.install.goSkills': 'Open Skills',
|
||||
'settings.install.sessionTitle': 'Installer session',
|
||||
'settings.install.sessionHint': 'This panel shows the full installer session, including tool calls and results.',
|
||||
'settings.install.sessionEmpty': 'No installer session yet',
|
||||
'settings.install.sessionEmptyHint': 'After you send the first installation request, the app will reuse the session chat pipeline and show the full execution trace here.',
|
||||
'settings.install.createFailed': 'Failed to create installer session',
|
||||
'settings.install.refreshDone': 'Plugin, MCP, and skill state refreshed',
|
||||
'settings.install.newConversationReady': 'A fresh installer conversation is ready; your next request will start a new install context.',
|
||||
|
||||
// Settings > Skills
|
||||
'settings.skills.title': 'Installed Skills',
|
||||
'settings.skills.description': 'Skills extend Claude with specialized capabilities. Manage skills in ~/.claude/skills/',
|
||||
|
||||
@ -52,6 +52,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.tab.providers': '服务商',
|
||||
'settings.tab.permissions': '权限',
|
||||
'settings.tab.general': '通用',
|
||||
'settings.tab.install': '安装中心',
|
||||
'settings.tab.skills': '技能',
|
||||
'settings.tab.mcp': 'MCP',
|
||||
'settings.tab.plugins': '插件',
|
||||
@ -289,6 +290,36 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.agents.source.flagSettings': 'CLI 参数',
|
||||
'settings.agents.source.built-in': '内置',
|
||||
|
||||
// Settings > Install Center
|
||||
'settings.install.eyebrow': 'AI 安装助手',
|
||||
'settings.install.title': '安装中心',
|
||||
'settings.install.description': '直接描述你想安装的 plugin、MCP 或 skill。底层会复用安装专用 session 去执行,并把结果同步回现有管理页。',
|
||||
'settings.install.targets.plugins': 'Plugins',
|
||||
'settings.install.targets.mcp': 'MCP',
|
||||
'settings.install.targets.skills': 'Skills',
|
||||
'settings.install.contextAuto': '默认上下文',
|
||||
'settings.install.composeTitle': '自然语言安装',
|
||||
'settings.install.composeHint': '描述你要安装什么、来源在哪里、希望落到用户级还是项目级。',
|
||||
'settings.install.placeholder': '例如:安装 plugin skill-creator@claude-plugins-official,并帮我应用到当前桌面端;或者:添加一个 user scope 的 MCP,地址是 https://example.com/mcp。',
|
||||
'settings.install.send': '发送安装请求',
|
||||
'settings.install.stop': '中断执行',
|
||||
'settings.install.refresh': '刷新安装状态',
|
||||
'settings.install.newConversation': '新建安装会话',
|
||||
'settings.install.contextDefault': '未指定目录时默认使用 Home 目录启动安装会话。',
|
||||
'settings.install.contextUsing': '当前安装会话目录:{path}',
|
||||
'settings.install.contextTitle': '执行目录',
|
||||
'settings.install.contextHint': '大部分用户级安装不依赖项目目录;如果你要安装 project skill 或 local/project MCP,可以先在这里选目录,再开始新的安装会话。',
|
||||
'settings.install.goPlugins': '查看插件',
|
||||
'settings.install.goMcp': '查看 MCP',
|
||||
'settings.install.goSkills': '查看技能',
|
||||
'settings.install.sessionTitle': '安装助手会话',
|
||||
'settings.install.sessionHint': '这里展示安装专用 session 的完整执行过程、工具调用和结果。',
|
||||
'settings.install.sessionEmpty': '还没有安装会话',
|
||||
'settings.install.sessionEmptyHint': '发送第一条安装请求后,这里会直接复用 session 聊天链路展示完整过程。',
|
||||
'settings.install.createFailed': '创建安装会话失败',
|
||||
'settings.install.refreshDone': '已刷新插件、MCP 和技能状态',
|
||||
'settings.install.newConversationReady': '已准备新的安装会话;下一条请求会启动新的安装上下文。',
|
||||
|
||||
// Settings > Skills
|
||||
'settings.skills.title': '已安装技能',
|
||||
'settings.skills.description': '技能扩展 Claude 的能力。在 ~/.claude/skills/ 中管理技能。',
|
||||
|
||||
42
desktop/src/lib/installAssistantPrompt.ts
Normal file
42
desktop/src/lib/installAssistantPrompt.ts
Normal file
@ -0,0 +1,42 @@
|
||||
const INSTALLER_RULES = `You are the installation assistant for Claude Code Haha desktop.
|
||||
|
||||
Your job is to help the user install or configure Claude Code extensions and integrations, especially:
|
||||
- Skills
|
||||
- MCP servers
|
||||
- Plugins and marketplaces
|
||||
|
||||
Execution rules:
|
||||
1. Act directly when the request is clear. Do not ask for confirmation on obvious next steps.
|
||||
2. Prefer existing Claude CLI capabilities and existing config formats over inventing new files.
|
||||
3. Inspect before changing things if the request depends on the current local state.
|
||||
4. Prefer user scope unless the user explicitly asks for project/local scope.
|
||||
5. For plugin installation, use the existing plugin and marketplace commands, and run reload/apply steps when needed.
|
||||
6. For MCP setup, prefer the existing MCP commands and config locations, and include required auth/header details when available.
|
||||
7. For skill installation, install to ~/.claude/skills unless the user clearly asks for a project-scoped skill.
|
||||
8. Keep unrelated workspace files untouched.
|
||||
|
||||
Command guidance:
|
||||
- Plugin install: run Bash with \`claude plugin install <plugin-id> --scope <scope>\`
|
||||
- For a normal plugin installation request, do both:
|
||||
1. \`claude plugin install <plugin-id> --scope <scope>\`
|
||||
2. \`claude plugin enable <plugin-id> --scope <scope>\`
|
||||
- Only skip the enable step if the user explicitly asks to install without enabling.
|
||||
- Marketplace add: run Bash with \`claude plugin marketplace add <source> --scope <scope>\`
|
||||
- MCP add: run Bash with \`claude mcp add ...\`
|
||||
- Do NOT run \`claude /plugin ...\` in Bash. Slash commands such as \`/plugin install\` are not shell syntax.
|
||||
- If the user gives a GitHub URL under \`anthropics/claude-plugins-official/external_plugins/<name>\`, treat the plugin id as \`<name>@claude-plugins-official\`.
|
||||
- If the user gives a skill marketplace page, inspect the page and prefer the exact published install command shown on that page.
|
||||
- For AI Templates skill pages (\`aitmpl.com/component/skill/...\`), if the page shows an install command like \`npx claude-code-templates@latest --skill <slug>\`, run that command directly in Bash.
|
||||
- After a successful plugin install or enable step, tell the user that the desktop Install Center will refresh Plugins / Skills / MCP views automatically. Only do extra reload steps if they are necessary for the current task.
|
||||
|
||||
Response rules:
|
||||
- Briefly explain what you are doing.
|
||||
- After finishing, summarize exactly what was installed or changed, where it landed, and whether the user should check Plugins, MCP, or Skills.
|
||||
- If you are blocked by missing information, ask only the minimal question needed to proceed.`
|
||||
|
||||
export function buildInstallerPrompt(userRequest: string) {
|
||||
return `${INSTALLER_RULES}
|
||||
|
||||
User request:
|
||||
${userRequest.trim()}`
|
||||
}
|
||||
@ -27,6 +27,7 @@ import { useUIStore, type SettingsTab } from '../stores/uiStore'
|
||||
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
|
||||
import { useUpdateStore } from '../stores/updateStore'
|
||||
import { formatBytes } from '../lib/formatBytes'
|
||||
import { InstallCenter } from '../components/settings/InstallCenter'
|
||||
|
||||
export function Settings() {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
|
||||
@ -49,6 +50,7 @@ export function Settings() {
|
||||
<TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
|
||||
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
|
||||
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
|
||||
<TabButton icon="download" label={t('settings.tab.install')} active={activeTab === 'install'} onClick={() => setActiveTab('install')} />
|
||||
<TabButton icon="dns" label={t('settings.tab.mcp')} active={activeTab === 'mcp'} onClick={() => setActiveTab('mcp')} />
|
||||
<TabButton icon="smart_toy" label={t('settings.tab.agents')} active={activeTab === 'agents'} onClick={() => setActiveTab('agents')} />
|
||||
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
|
||||
@ -66,6 +68,7 @@ export function Settings() {
|
||||
{activeTab === 'permissions' && <PermissionSettings />}
|
||||
{activeTab === 'general' && <GeneralSettings />}
|
||||
{activeTab === 'adapters' && <AdapterSettings />}
|
||||
{activeTab === 'install' && <InstallCenter />}
|
||||
{activeTab === 'mcp' && <McpSettings />}
|
||||
{activeTab === 'agents' && <AgentsSettings />}
|
||||
{activeTab === 'skills' && <SkillSettings />}
|
||||
|
||||
@ -86,7 +86,12 @@ type ChatStore = {
|
||||
getSession: (sessionId: string) => PerSessionState
|
||||
connectToSession: (sessionId: string) => void
|
||||
disconnectSession: (sessionId: string) => void
|
||||
sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => void
|
||||
sendMessage: (
|
||||
sessionId: string,
|
||||
content: string,
|
||||
attachments?: AttachmentRef[],
|
||||
options?: { displayContent?: string },
|
||||
) => void
|
||||
respondToPermission: (
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
@ -206,8 +211,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
})
|
||||
},
|
||||
|
||||
sendMessage: (sessionId, content, attachments?) => {
|
||||
const userFacingContent = content.trim()
|
||||
sendMessage: (sessionId, content, attachments, options) => {
|
||||
const userFacingContent =
|
||||
options?.displayContent?.trim() || content.trim()
|
||||
const isMemberSession = !!useTeamStore.getState().getMemberBySessionId(sessionId)
|
||||
const uiAttachments: UIAttachment[] | undefined =
|
||||
attachments && attachments.length > 0
|
||||
|
||||
@ -28,7 +28,18 @@ export type Toast = {
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'mcp' | 'agents' | 'skills' | 'plugins' | 'computerUse' | 'about'
|
||||
export type SettingsTab =
|
||||
| 'providers'
|
||||
| 'permissions'
|
||||
| 'general'
|
||||
| 'adapters'
|
||||
| 'install'
|
||||
| 'mcp'
|
||||
| 'agents'
|
||||
| 'skills'
|
||||
| 'plugins'
|
||||
| 'computerUse'
|
||||
| 'about'
|
||||
|
||||
type ActiveView = 'code' | 'scheduled' | 'terminal' | 'history' | 'settings'
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user