mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
This folds together the desktop-side fixes needed before broader rollout. Session resume no longer deadlocks waiting on init, Mermaid and inline image output render inside chat, task and sub-agent state stay visible during execution, local build/release paths are safer, and Feishu/Telegram now expose lightweight mobile commands (/help, /status, /clear) without adding a new adapter-specific protocol. Constraint: Desktop releases must publish updater artifacts from non-draft GitHub releases Constraint: IM commands need short, phone-friendly responses and low operational complexity Rejected: Add a dedicated IM command API surface | re-used existing slash commands and session/task REST endpoints to keep adapters thin Rejected: Wait for task_update push events in WebUI | added low-risk polling because the current frontend ignores that event path Confidence: medium Scope-risk: broad Reversibility: clean Directive: Keep IM command replies terse and mobile-first, and merge local fallback slash commands when server-provided lists are partial Tested: cd desktop && bun x vitest run src/components/chat/MermaidRenderer.test.tsx src/components/markdown/MarkdownRenderer.test.tsx Tested: cd desktop && bun x vitest run src/components/chat/composerUtils.test.ts src/pages/ActiveSession.test.tsx src/stores/chatStore.test.ts Tested: cd desktop && bun run lint Tested: bun test src/server/__tests__/conversations.test.ts --test-name-pattern "SDK init arrives only after the first user turn" --timeout 60000 Tested: cd adapters && bun test common/ feishu/ telegram/ Tested: cd adapters && bunx tsc --noEmit Not-tested: Full GitHub Actions release run on all three desktop platforms Not-tested: Local DMG packaging end-to-end on Apple Silicon Not-tested: Real Feishu/Telegram device sessions against a live adapter process
108 lines
3.4 KiB
TypeScript
108 lines
3.4 KiB
TypeScript
export type RecentProject = {
|
|
projectPath: string
|
|
realPath: string
|
|
projectName: string
|
|
isGit: boolean
|
|
repoName: string | null
|
|
branch: string | null
|
|
modifiedAt: string
|
|
sessionCount: number
|
|
}
|
|
|
|
export type GitInfo = {
|
|
branch: string | null
|
|
repoName: string | null
|
|
workDir: string
|
|
changedFiles: number
|
|
}
|
|
|
|
export type SessionTask = {
|
|
id: string
|
|
subject: string
|
|
status: 'pending' | 'in_progress' | 'completed'
|
|
}
|
|
|
|
export class AdapterHttpClient {
|
|
readonly httpBaseUrl: string
|
|
|
|
constructor(wsUrl: string) {
|
|
this.httpBaseUrl = wsUrl
|
|
.replace(/^ws:/, 'http:')
|
|
.replace(/^wss:/, 'https:')
|
|
.replace(/\/$/, '')
|
|
}
|
|
|
|
async createSession(workDir: string): Promise<string> {
|
|
const res = await fetch(`${this.httpBaseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workDir }),
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ message: res.statusText }))
|
|
throw new Error(`Failed to create session: ${(err as any).message}`)
|
|
}
|
|
const data = (await res.json()) as { sessionId: string }
|
|
return data.sessionId
|
|
}
|
|
|
|
async listRecentProjects(): Promise<RecentProject[]> {
|
|
const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`)
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to list projects: ${res.statusText}`)
|
|
}
|
|
const data = (await res.json()) as { projects: RecentProject[] }
|
|
return data.projects
|
|
}
|
|
|
|
/**
|
|
* Match a project by index (1-based) or fuzzy name from recent projects.
|
|
* Returns { project, ambiguous[] } — ambiguous is set when multiple projects match.
|
|
*/
|
|
async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> {
|
|
const projects = await this.listRecentProjects()
|
|
|
|
// Try as 1-based index
|
|
const num = parseInt(query, 10)
|
|
if (!isNaN(num) && num >= 1 && num <= projects.length && String(num) === query.trim()) {
|
|
return { project: projects[num - 1] }
|
|
}
|
|
|
|
const q = query.toLowerCase()
|
|
|
|
// Exact project name match
|
|
const exact = projects.find(p => p.projectName.toLowerCase() === q)
|
|
if (exact) return { project: exact }
|
|
|
|
// Fuzzy: name or path contains query
|
|
const matches = projects.filter(p =>
|
|
p.projectName.toLowerCase().includes(q) ||
|
|
p.realPath.toLowerCase().includes(q)
|
|
)
|
|
if (matches.length === 1) return { project: matches[0] }
|
|
if (matches.length > 1) return { ambiguous: matches }
|
|
|
|
return {}
|
|
}
|
|
|
|
async getGitInfo(sessionId: string): Promise<GitInfo> {
|
|
const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`)
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ message: res.statusText }))
|
|
throw new Error(`Failed to load git info: ${(err as any).message}`)
|
|
}
|
|
return (await res.json()) as GitInfo
|
|
}
|
|
|
|
async getTasksForSession(sessionId: string): Promise<SessionTask[]> {
|
|
const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`)
|
|
if (!res.ok) {
|
|
if (res.status === 404) return []
|
|
const err = await res.json().catch(() => ({ message: res.statusText }))
|
|
throw new Error(`Failed to load tasks: ${(err as any).message}`)
|
|
}
|
|
const data = (await res.json()) as { tasks?: SessionTask[] }
|
|
return Array.isArray(data.tasks) ? data.tasks : []
|
|
}
|
|
}
|