diff --git a/.github/workflows/universal-client.yml b/.github/workflows/universal-client.yml new file mode 100644 index 0000000..cf927b1 --- /dev/null +++ b/.github/workflows/universal-client.yml @@ -0,0 +1,32 @@ +name: Universal client +on: + push: + branches: [main] + pull_request: + paths: ['src/**', 'scripts/postbuild.mjs', 'tests/**', 'apps/universal/**', 'package*.json', 'tsconfig.json', '.github/workflows/universal-client.yml'] +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: | + package-lock.json + apps/universal/package-lock.json + - run: npm ci + - run: npm run build + - run: npm test + - run: npm ci --prefix apps/universal + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + - run: npm run typecheck --prefix apps/universal + - run: npm run export --prefix apps/universal + env: + EXPO_OFFLINE: '1' + - uses: actions/upload-artifact@v4 + with: + name: monkey-universal-export + path: apps/universal/dist diff --git a/.gitignore b/.gitignore index e97897a..e2592ff 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,9 @@ MonkeyElectron/dist-electron/ # Never commit personal data # ~/.monkey-cli/ lives outside this repo, but just in case: .monkey-cli/ + +# Universal client build output +apps/universal/.expo/ +apps/universal/ios/ +apps/universal/android/ +apps/universal/release/ diff --git a/README.md b/README.md index 3ddad4e..c95f0e3 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,30 @@ Monkey remembers things across sessions. It stores knowledge in `~/.monkey-cli/m - **`/clean`** — full cleanup: stale sessions + LLM-reviewed memory deduplication - **Safety guard** — all deletions are restricted to `~/.monkey-cli/` only (path validation + traversal protection) +## 📱 Universal client (iOS / Android / Web / Desktop) + +The new Expo client lives in `apps/universal`. It connects to your own Monkey host +using authenticated WebSockets; agent tools still execute on that host. + +```bash +npm ci +npm run build +npm ci --prefix apps/universal +npm run export:web --prefix apps/universal +# Configure the host with `monkey` first, then: +npm run serve +``` + +Open `http://127.0.0.1:8787`, then enter the connection key from +`~/.monkey-cli/server-token`. Phones need a reachable HTTPS host address. +The client supports shared sessions, streaming, tool approvals, model switching, +image/text attachments, and reconnect recovery. It requires Node 22.13+. + +See [多端改造计划、运行方式与验收边界](docs/universal-client-plan.md) for native +builds, desktop packaging, migration, HTTPS, and validation status. Native store +releases still require signing and device testing. The legacy clients below are +preserved; stop them before using the new service against the same session folder. + ## 🍎 macOS Native App Monkey also comes as a native macOS app — no terminal needed. diff --git a/apps/universal/App.tsx b/apps/universal/App.tsx new file mode 100644 index 0000000..cb73c3e --- /dev/null +++ b/apps/universal/App.tsx @@ -0,0 +1,224 @@ +import React, { useEffect, useRef, useState } from 'react' +import { ActivityIndicator, AppState, BackHandler, FlatList, Image, KeyboardAvoidingView, Linking, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useWindowDimensions, View } from 'react-native' +import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context' +import { StatusBar } from 'expo-status-bar' +import Feather from '@expo/vector-icons/Feather' +import Markdown from 'react-native-markdown-display' +import * as Clipboard from 'expo-clipboard' +import { MonkeyClient, endpoint, type Data, type Status } from './src/client' +import { readConnection, saveConnection, clearConnection } from './src/storage' +import { pickImage, pickTextFile, type Attachment } from './src/attachments' + +type IconName = React.ComponentProps['name'] +const C = { ink: '#27251F', muted: '#89877F', orange: '#E46A32', pale: '#FFF0E6', line: '#E9E7E1', paper: '#FFFFFF', bg: '#F8F7F3', green: '#3C876C' } +const statusText: Record = { offline: '未连接', connecting: '正在连接', connected: '已连接', reconnecting: '正在重连', 'auth-error': '密钥不正确' } +function Icon({ name, color = C.ink, size = 19 }: { name: IconName; color?: string; size?: number }) { return } +function Button({ label, icon, onPress, disabled, primary }: { label: string; icon?: IconName; onPress: () => void; disabled?: boolean; primary?: boolean }) { + return [s.button, primary && s.primary, disabled && { opacity: 0.4 }, pressed && { opacity: 0.7 }]}>{icon && }{label} +} +function AppContent() { + const wide = useWindowDimensions().width >= 900 + const [status, setStatus] = useState('offline') + const [sessions, setSessions] = useState([]) + const [snapshot, setSnapshot] = useState(null) + const [models, setModels] = useState([]) + const [screen, setScreen] = useState<'chat' | 'sessions' | 'settings'>('chat') + const [address, setAddress] = useState(Platform.OS === 'web' && typeof location !== 'undefined' ? location.origin : '') + const [token, setToken] = useState('') + const [draft, setDraft] = useState('') + const [files, setFiles] = useState([]) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [modelDraft, setModelDraft] = useState('') + const [loading, setLoading] = useState(false) + const [sending, setSending] = useState(false) + const [confirm, setConfirm] = useState<{ title: string; body: string; action: () => void } | null>(null) + const [rename, setRename] = useState(null) + const activeId = useRef(null) + const loadVersion = useRef(0) + const messagesRef = useRef(null) + const followScroll = useRef(true) + const callbacks = useRef({ ready: () => {}, event: (_: Data) => {} }) + const client = useRef(null) + if (!client.current) client.current = new MonkeyClient(setStatus, event => callbacks.current.event(event), () => callbacks.current.ready()) + const rpc = client.current + const online = status === 'connected' + const busy = !!snapshot?.busy + const fail = (err: unknown) => setError(err instanceof Error ? err.message : String(err)) + async function attempt(action: () => Promise) { setError(''); try { await action() } catch (err) { fail(err) } } + async function refresh() { + const result = await rpc.request('session_list'); setSessions(result.sessions) + if (activeId.current && !result.sessions.some((item: Data) => item.id === activeId.current)) { + activeId.current = null; setSnapshot(null) + if (result.sessions[0]) await openSession(result.sessions[0].id) + } + } + async function openSession(id: string) { + const version = ++loadVersion.current + activeId.current = id; setLoading(true); setSnapshot(null); setDraft(''); setFiles([]); setScreen('chat'); followScroll.current = true + try { + const result = await rpc.request('session_get', { sessionId: id }) + if (version === loadVersion.current) setSnapshot(result) + } finally { if (version === loadVersion.current) setLoading(false) } + } + async function newSession() { + const result = await rpc.request('session_new'); await openSession(result.sessionId) + } + async function change(method: string, params: Data = {}) { + const id = activeId.current + if (!id) return + const result = await rpc.request(method, { ...params, sessionId: id }) + if (activeId.current === id && result.sessionId) setSnapshot(result) + await refresh() + } + callbacks.current.ready = () => { + void attempt(async () => { + const result = await rpc.request('initialize'); setModels(result.models); setSessions(result.sessions) + const id = activeId.current + if (id && result.sessions.some((item: Data) => item.id === id)) { + // Restore in-flight state without discarding an unsent draft on reconnect. + const restored = await rpc.request('session_get', { sessionId: id }) + if (activeId.current === id) setSnapshot(restored) + } else if (result.sessions[0]) await openSession(result.sessions[0].id) + else await newSession() + }) + } + callbacks.current.event = event => { + const p = event.params || {} + if (event.method === 'sessions/changed') { void attempt(refresh); return } + if (p.sessionId !== activeId.current) return + if (event.method === 'run/done') { if (p.error) setError(p.error); else if (p.aborted) setNotice('任务已停止'); return } + setSnapshot(previous => { + if (event.method === 'session/state') return p + if (!previous) return previous + if (event.method === 'approval/request') return { ...previous, approval: p } + if (event.method === 'approval/cleared') return { ...previous, approval: null } + const run = previous.run || { text: '', tools: [], usage: {} } + if (event.method === 'stream/text') return { ...previous, busy: true, run: { ...run, text: run.text + p.text } } + if (event.method === 'stream/usage') return { ...previous, run: { ...run, usage: p } } + if (event.method === 'stream/tool_start') return { ...previous, run: { ...run, tools: [...run.tools, p] } } + if (event.method === 'stream/tool_result') return { ...previous, run: { ...run, tools: run.tools.map((t: Data) => t.id === p.id ? { ...t, ...p } : t) } } + return previous + }) + } + useEffect(() => { + let alive = true + readConnection().then(connection => { + if (!alive || !connection) return + setAddress(connection.address); setToken(connection.token) + if (connection.token) rpc.connect(connection.address, connection.token) + }).catch(fail) + const subscription = AppState.addEventListener('change', state => { if (state === 'active') rpc.resume() }) + return () => { alive = false; subscription.remove(); rpc.disconnect() } + }, []) + useEffect(() => { + const back = BackHandler.addEventListener('hardwareBackPress', () => { + if (screen !== 'chat') { setScreen('chat'); return true } + return false + }) + return () => back.remove() + }, [screen]) + useEffect(() => { if (!notice) return; const timer = setTimeout(() => setNotice(''), 3000); return () => clearTimeout(timer) }, [notice]) + async function connect() { + endpoint(address) + if (token.trim().length < 32) throw new Error('请粘贴服务端的连接密钥') + await saveConnection({ address: address.trim(), token: token.trim() }) + activeId.current = null; setSnapshot(null); setSessions([]); ++loadVersion.current + rpc.connect(address, token); setScreen('chat') + } + async function send() { + if (!activeId.current || sending || busy || !online || (!draft.trim() && !files.length)) return + const prompt = draft; const attachments = files + setSending(true); setError(''); followScroll.current = true + try { + await rpc.request('chat', { sessionId: activeId.current, prompt, attachments }) + setDraft(''); setFiles([]) + } catch (err) { fail(err) } finally { setSending(false) } + } + async function addFile(image: boolean) { + if (files.length >= 4) throw new Error('最多添加 4 个附件') + const file = await (image ? pickImage() : pickTextFile()) + if (file) setFiles(old => [...old, file].slice(0, 4)) + } + function askDelete() { + setConfirm({ title: '删除这个会话?', body: '此操作会删除服务端的会话记录,所有设备都会同步。', action: () => void attempt(async () => { + await rpc.request('session_delete', { sessionId: activeId.current }); activeId.current = null; setSnapshot(null) + const result = await rpc.request('session_list'); setSessions(result.sessions) + if (result.sessions[0]) await openSession(result.sessions[0].id); else await newSession() + }) }) + } + const sessionList = + 你的会话{sessions.length} + + {!sessions.length && 连接后,会话会在这里同步。} + {sessions.map(item => void attempt(() => openSession(item.id))} style={[s.session, item.id === snapshot?.sessionId && s.selected]}> + {item.title}{item.messageCount} 条消息 · {item.updatedAt ? new Date(item.updatedAt).toLocaleDateString() : ''} + )} + + + const rows: Data[] = snapshot?.messages || [] + const allRows = [...rows, ...(snapshot?.run?.text ? [{ role: 'assistant', content: snapshot.run.text }] : []), ...(snapshot?.run?.tools || []).map((tool: Data) => ({ role: 'tool', toolName: tool.name, content: tool.result || tool.summary, status: tool.status }))] + const chat = + {snapshot?.sessionTitle === 'New Chat' ? '新的可能,从这里开始' : snapshot?.sessionTitle || '和 Monkey 一起'}{snapshot?.model || '你的 AI 助手,随时在身边'}{!snapshot && online &&