@mobius-os/mobius 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -0
- package/bin/mobius-tui.js +12 -0
- package/package.json +29 -0
- package/src/App.tsx +145 -0
- package/src/aimux.ts +294 -0
- package/src/api.ts +206 -0
- package/src/components/AimuxStatus.tsx +45 -0
- package/src/components/Chat.tsx +519 -0
- package/src/components/Login.tsx +88 -0
- package/src/components/PrepScreen.tsx +368 -0
- package/src/components/ResumePicker.tsx +63 -0
- package/src/components/primitives.tsx +241 -0
- package/src/config.ts +159 -0
- package/src/hooks/useChat.ts +332 -0
- package/src/lib/entry-view.ts +351 -0
- package/src/main.tsx +13 -0
- package/src/markdown.ts +160 -0
- package/src/sse.ts +126 -0
- package/src/types.ts +216 -0
package/src/api.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mobius HTTP API client (Node fetch + Bearer auth).
|
|
3
|
+
*
|
|
4
|
+
* Endpoint map + payload shapes mirror the mobius web frontend's `api()` helper
|
|
5
|
+
* (frontend/src/store.ts) and the backend routes (backend/routes/*). Auth is a
|
|
6
|
+
* bearer token (header `Authorization: Bearer <jwt>`); there is no cookie auth.
|
|
7
|
+
*/
|
|
8
|
+
import type {
|
|
9
|
+
AuthConfig,
|
|
10
|
+
Issue,
|
|
11
|
+
LoginResponse,
|
|
12
|
+
Memory,
|
|
13
|
+
PcClientMetadata,
|
|
14
|
+
Project,
|
|
15
|
+
Session,
|
|
16
|
+
SessionModelOption,
|
|
17
|
+
SessionRuntimeStatus,
|
|
18
|
+
Skill,
|
|
19
|
+
User,
|
|
20
|
+
} from './types.js'
|
|
21
|
+
|
|
22
|
+
export class ApiError extends Error {
|
|
23
|
+
status: number
|
|
24
|
+
body: any
|
|
25
|
+
constructor(message: string, status: number, body: any) {
|
|
26
|
+
super(message)
|
|
27
|
+
this.name = 'ApiError'
|
|
28
|
+
this.status = status
|
|
29
|
+
this.body = body
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function trimSlash(s: string): string {
|
|
34
|
+
return s.replace(/\/+$/, '')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** POST /api/auth/login — passwordless when ENABLE_PASSWORD_LOGIN=false. */
|
|
38
|
+
export async function login(server: string, username: string, password?: string): Promise<LoginResponse> {
|
|
39
|
+
const res = await fetch(`${trimSlash(server)}/api/auth/login`, {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { 'content-type': 'application/json' },
|
|
42
|
+
body: JSON.stringify(password ? { username, password } : { username }),
|
|
43
|
+
})
|
|
44
|
+
const data: any = await res.json().catch(() => ({}))
|
|
45
|
+
if (!res.ok) throw new ApiError(data?.error || `登录失败 (${res.status})`, res.status, data)
|
|
46
|
+
const token: string | undefined = data.token || data.jwt || data.access_token
|
|
47
|
+
if (!token) throw new ApiError('登录响应缺少 token', res.status, data)
|
|
48
|
+
return { token, user: data.user as User }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** GET /api/auth/config → { password_required }. */
|
|
52
|
+
export async function getAuthConfig(server: string): Promise<AuthConfig> {
|
|
53
|
+
const res = await fetch(`${trimSlash(server)}/api/auth/config`)
|
|
54
|
+
const data: any = await res.json().catch(() => ({}))
|
|
55
|
+
return { password_required: data?.password_required ?? true }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** GET /api/auth/me → current user (validates the token). */
|
|
59
|
+
export async function getMe(server: string, token: string): Promise<User> {
|
|
60
|
+
const res = await fetch(`${trimSlash(server)}/api/auth/me`, { headers: { Authorization: `Bearer ${token}` } })
|
|
61
|
+
const data: any = await res.json().catch(() => ({}))
|
|
62
|
+
if (!res.ok) throw new ApiError(data?.error || `HTTP ${res.status}`, res.status, data)
|
|
63
|
+
return data as User
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class MobiusClient {
|
|
67
|
+
server: string
|
|
68
|
+
token: string
|
|
69
|
+
constructor(server: string, token: string) {
|
|
70
|
+
this.server = trimSlash(server)
|
|
71
|
+
this.token = token
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
setToken(token: string): void {
|
|
75
|
+
this.token = token
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async request<T>(p: string, init?: RequestInit): Promise<T> {
|
|
79
|
+
const res = await fetch(`${this.server}${p}`, {
|
|
80
|
+
...init,
|
|
81
|
+
headers: {
|
|
82
|
+
...(init?.body && !(init.body instanceof FormData) ? { 'content-type': 'application/json' } : {}),
|
|
83
|
+
Authorization: `Bearer ${this.token}`,
|
|
84
|
+
...init?.headers,
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
const text = await res.text()
|
|
88
|
+
let data: any = null
|
|
89
|
+
if (text) { try { data = JSON.parse(text) } catch { data = text } }
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
throw new ApiError((data && (data.error || data.message)) || `HTTP ${res.status}`, res.status, data)
|
|
92
|
+
}
|
|
93
|
+
return data as T
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── projects ──────────────────────────────────────────────────────────────
|
|
97
|
+
async listProjects(): Promise<Project[]> {
|
|
98
|
+
const r = await this.request<any>('/api/projects?all=true')
|
|
99
|
+
return Array.isArray(r) ? r : (r.projects ?? [])
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async createProject(body: {
|
|
103
|
+
name: string
|
|
104
|
+
description?: string
|
|
105
|
+
bindPath?: string
|
|
106
|
+
bindPathManual?: boolean
|
|
107
|
+
defaultUseWorktree?: boolean
|
|
108
|
+
visibility?: string
|
|
109
|
+
}): Promise<Project> {
|
|
110
|
+
return this.request<Project>('/api/projects', { method: 'POST', body: JSON.stringify(body) })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── issues (tasks) ────────────────────────────────────────────────────────
|
|
114
|
+
async listIssues(projectId: string, status?: 'active' | 'completed'): Promise<Issue[]> {
|
|
115
|
+
const q = status ? `?status=${status}` : ''
|
|
116
|
+
const r = await this.request<any>(`/api/projects/${projectId}/issues${q}`)
|
|
117
|
+
return Array.isArray(r) ? r : (r.issues ?? [])
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async createIssue(projectId: string, body: {
|
|
121
|
+
title: string
|
|
122
|
+
description?: string
|
|
123
|
+
use_worktree?: boolean
|
|
124
|
+
}): Promise<Issue> {
|
|
125
|
+
return this.request<Issue>(`/api/projects/${projectId}/issues`, { method: 'POST', body: JSON.stringify(body) })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── sessions ──────────────────────────────────────────────────────────────
|
|
129
|
+
async listSessions(issueId: string): Promise<Session[]> {
|
|
130
|
+
const r = await this.request<any>(`/api/issues/${issueId}/sessions`)
|
|
131
|
+
return Array.isArray(r) ? r : (r.sessions ?? [])
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async createSession(issueId: string, body: {
|
|
135
|
+
name: string
|
|
136
|
+
description?: string
|
|
137
|
+
model?: string
|
|
138
|
+
language?: 'zh' | 'en'
|
|
139
|
+
excluded_skill_ids?: string[]
|
|
140
|
+
excluded_memory_ids?: string[]
|
|
141
|
+
continue_from_session_id?: string
|
|
142
|
+
pc_client_metadata?: PcClientMetadata
|
|
143
|
+
}): Promise<Session> {
|
|
144
|
+
return this.request<Session>(`/api/issues/${issueId}/sessions`, { method: 'POST', body: JSON.stringify(body) })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async sendMessage(sessionId: string, content: string, requestId?: string): Promise<{ ok: boolean; session_id: string; turn_number: number }> {
|
|
148
|
+
return this.request(`/api/sessions/${sessionId}/messages`, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
body: JSON.stringify({ content, request_id: requestId }),
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async stopSession(sessionId: string): Promise<void> {
|
|
155
|
+
await this.request(`/api/sessions/${sessionId}/stop`, { method: 'POST', body: '{}' })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The backend source of truth for the live agent process and work state. */
|
|
159
|
+
async sessionStatus(sessionId: string, signal?: AbortSignal): Promise<SessionRuntimeStatus> {
|
|
160
|
+
return this.request<SessionRuntimeStatus>(`/api/sessions/${encodeURIComponent(sessionId)}/status`, { signal })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── preference lookups ────────────────────────────────────────────────────
|
|
164
|
+
async modelOptions(): Promise<SessionModelOption[]> {
|
|
165
|
+
const r = await this.request<any>('/api/sessions/model-options')
|
|
166
|
+
return Array.isArray(r) ? r : (r.options ?? [])
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async defaultModel(): Promise<{ model: string | null }> {
|
|
170
|
+
return this.request('/api/sessions/default-model')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async listSkills(projectId: string): Promise<Skill[]> {
|
|
174
|
+
const r = await this.request<any>(`/api/projects/${projectId}/skills`)
|
|
175
|
+
return Array.isArray(r) ? r : (r.skills ?? [])
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async listMemories(projectId: string): Promise<Memory[]> {
|
|
179
|
+
const r = await this.request<any>(`/api/projects/${projectId}/memories`)
|
|
180
|
+
return Array.isArray(r) ? r : (r.memories ?? [])
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Recent sessions across a whole project (for /resume). The backend has no
|
|
185
|
+
* single clean project-scoped sessions list route, so we aggregate over the
|
|
186
|
+
* project's issues, flatten, sort by last_active DESC, and cap at `limit`.
|
|
187
|
+
*/
|
|
188
|
+
async listProjectSessions(projectId: string, limit = 32): Promise<Session[]> {
|
|
189
|
+
const issues = await this.listIssues(projectId)
|
|
190
|
+
const all: Session[] = []
|
|
191
|
+
// Fetch sessions for each issue in parallel.
|
|
192
|
+
await Promise.all(issues.map(async (iss) => {
|
|
193
|
+
try {
|
|
194
|
+
const ss = await this.listSessions(iss.id)
|
|
195
|
+
for (const s of ss) {
|
|
196
|
+
if (!s.project_id) s.project_id = projectId
|
|
197
|
+
if (!s.issue_id) s.issue_id = iss.id
|
|
198
|
+
if (!s.issue_title) s.issue_title = iss.title
|
|
199
|
+
}
|
|
200
|
+
all.push(...ss)
|
|
201
|
+
} catch { /* ignore per-issue failures */ }
|
|
202
|
+
}))
|
|
203
|
+
all.sort((a, b) => (b.last_active || '').localeCompare(a.last_active || ''))
|
|
204
|
+
return all.slice(0, limit)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import type { AimuxStatus } from '../aimux.js'
|
|
4
|
+
|
|
5
|
+
const STYLE: Record<AimuxStatus['state'], { icon: string; color: 'green' | 'yellow' | 'red' | 'gray' }> = {
|
|
6
|
+
connected: { icon: '●', color: 'green' },
|
|
7
|
+
starting: { icon: '◐', color: 'yellow' },
|
|
8
|
+
failed: { icon: '●', color: 'red' },
|
|
9
|
+
stopped: { icon: '○', color: 'gray' },
|
|
10
|
+
disabled: { icon: '○', color: 'gray' },
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function AimuxStatusLine({ status, compact = false }: { status: AimuxStatus; compact?: boolean }) {
|
|
14
|
+
const style = STYLE[status.state]
|
|
15
|
+
const phase = status.phase && !['idle', 'connected'].includes(status.phase) ? ` · ${phaseLabel(status.phase)}` : ''
|
|
16
|
+
const detail = status.detail || stateLabel(status.state)
|
|
17
|
+
return (
|
|
18
|
+
<Box>
|
|
19
|
+
<Text color={style.color}>{style.icon}</Text>
|
|
20
|
+
<Text dimColor={status.state === 'disabled' || status.state === 'stopped'}>
|
|
21
|
+
{' '}AIMUX{phase} · {compact ? compactDetail(detail) : detail}
|
|
22
|
+
</Text>
|
|
23
|
+
</Box>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stateLabel(state: AimuxStatus['state']): string {
|
|
28
|
+
if (state === 'connected') return '已连接 · 远程 MCP 工具就绪'
|
|
29
|
+
if (state === 'starting') return '连接中…'
|
|
30
|
+
if (state === 'failed') return '连接失败'
|
|
31
|
+
if (state === 'disabled') return '已关闭'
|
|
32
|
+
return '等待登录'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function phaseLabel(phase: NonNullable<AimuxStatus['phase']>): string {
|
|
36
|
+
const labels: Record<NonNullable<AimuxStatus['phase']>, string> = {
|
|
37
|
+
idle: '等待', python: '检查 Python', venv: '创建环境', install: '安装',
|
|
38
|
+
connecting: '连接', heartbeat: '心跳', retrying: '重连', connected: '在线',
|
|
39
|
+
}
|
|
40
|
+
return labels[phase]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function compactDetail(value: string): string {
|
|
44
|
+
return value.length > 96 ? `${value.slice(0, 95)}…` : value
|
|
45
|
+
}
|