diff --git a/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts b/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts index 27550cd04a..a345b7f003 100644 --- a/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts +++ b/apps/desktop/src/main/controllers/GatewayConnectionCtr.ts @@ -29,6 +29,7 @@ import { type ILocalSystemService, LocalSystemExecutionRuntime } from '@lobechat import GatewayConnectionService from '@/services/gatewayConnectionSrv'; import ImessageBridgeService from '@/services/imessageBridgeSrv'; +import GitCtr from './GitCtr'; import HeterogeneousAgentCtr from './HeterogeneousAgentCtr'; import { ControllerModule, IpcMethod } from './index'; import LocalFileCtr from './LocalFileCtr'; @@ -168,6 +169,10 @@ export default class GatewayConnectionCtr extends ControllerModule { return this.app.getController(WorkspaceCtr); } + private get gitCtr() { + return this.app.getController(GitCtr); + } + private get shellCommandCtr() { return this.app.getController(ShellCommandCtr); } @@ -356,6 +361,10 @@ export default class GatewayConnectionCtr extends ControllerModule { return this.workspaceCtr.initWorkspace(params as InitWorkspaceParams); } + case 'gitInfo': { + return this.gitCtr.gitInfo(params as { isGithub?: boolean; scope: string }); + } + default: { throw new Error(`Unknown device RPC method: ${method}`); } diff --git a/apps/desktop/src/main/controllers/GitCtr.ts b/apps/desktop/src/main/controllers/GitCtr.ts index eeb5e4dcfa..e161dd6b00 100644 --- a/apps/desktop/src/main/controllers/GitCtr.ts +++ b/apps/desktop/src/main/controllers/GitCtr.ts @@ -22,8 +22,16 @@ import type { GitWorkingTreeStatus, SubmoduleWorkingTreePatches, } from '@lobechat/electron-client-ipc'; +import { + type DeviceGitInfo, + getGitAheadBehind as computeGitAheadBehind, + getGitBranch as computeGitBranch, + getGitWorkingTreeStatus as computeGitWorkingTreeStatus, + getLinkedPullRequest as computeLinkedPullRequest, + gitInfo as computeGitInfo, +} from '@lobechat/local-file-shell'; -import { detectRepoType, resolveGitDir } from '@/utils/git'; +import { detectRepoType } from '@/utils/git'; import { createLogger } from '@/utils/logger'; import { ControllerModule, IpcMethod } from './index'; @@ -450,23 +458,17 @@ export default class GitController extends ControllerModule { */ @IpcMethod() async getGitBranch(dirPath: string): Promise { - try { - const gitDir = await resolveGitDir(dirPath); - if (!gitDir) return {}; + return computeGitBranch(dirPath); + } - const head = (await readFile(path.join(gitDir, 'HEAD'), 'utf8')).trim(); - const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(head); - if (refMatch) { - return { branch: refMatch[1] }; - } - // Detached HEAD — HEAD file contains the full sha - if (/^[\da-f]{40}$/i.test(head)) { - return { branch: head.slice(0, 7), detached: true }; - } - return {}; - } catch { - return {}; - } + /** + * Aggregate git status (branch + linked PR + working tree + ahead/behind) for a + * directory. The single entry point shared by the local desktop display, the + * device `gitInfo` RPC, and the CLI — implemented in `@lobechat/local-file-shell`. + */ + @IpcMethod() + async gitInfo(params: { isGithub?: boolean; scope: string }): Promise { + return computeGitInfo(params); } /** @@ -479,58 +481,7 @@ export default class GitController extends ControllerModule { branch: string; path: string; }): Promise { - const { path: dirPath, branch } = payload; - if (!branch) { - return { pullRequest: null, status: 'ok' }; - } - - const execFileAsync = promisify(execFile); - try { - const { stdout } = await execFileAsync( - 'gh', - [ - 'pr', - 'list', - '--head', - branch, - '--state', - 'open', - '--limit', - '5', - '--json', - 'number,url,title,state', - ], - { cwd: dirPath, timeout: 8000 }, - ); - const parsed = JSON.parse(stdout.trim() || '[]') as Array<{ - number: number; - state: string; - title: string; - url: string; - }>; - if (parsed.length === 0) { - return { pullRequest: null, status: 'ok' }; - } - const [primary, ...rest] = parsed; - return { - extraCount: rest.length, - pullRequest: primary, - status: 'ok', - }; - } catch (error: any) { - const code = error?.code; - const stderr: string = error?.stderr ?? ''; - // `gh` binary not on PATH - if (code === 'ENOENT') { - return { pullRequest: null, status: 'gh-missing' }; - } - // gh reports auth issues via stderr; treat as a soft-fail - if (/auth\s+login|not\s+logged\s+in|authentication/i.test(stderr)) { - return { pullRequest: null, status: 'gh-missing' }; - } - logger.debug('[getLinkedPullRequest] failed', { branch, code, stderr }); - return { pullRequest: null, status: 'error' }; - } + return computeLinkedPullRequest(payload); } /** @@ -635,42 +586,7 @@ export default class GitController extends ControllerModule { */ @IpcMethod() async getGitWorkingTreeStatus(dirPath: string): Promise { - const execFileAsync = promisify(execFile); - try { - const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-u', '-z'], { - cwd: dirPath, - timeout: 5000, - }); - const tokens = stdout.split('\0'); - let added = 0; - let modified = 0; - let deleted = 0; - let i = 0; - while (i < tokens.length) { - const entry = tokens[i]; - i++; - if (entry.length < 2) continue; - const x = entry[0]; - const y = entry[1]; - // R/C entries carry an extra source-path token we must consume. - if (x === 'R' || x === 'C') i++; - if (x === '?' && y === '?') { - added++; - } else if (x === '!' && y === '!') { - // ignored — skip - } else if (x === 'D' || y === 'D') { - deleted++; - } else if (x === 'A' || y === 'A') { - added++; - } else { - modified++; - } - } - const total = added + modified + deleted; - return { added, clean: total === 0, deleted, modified, total }; - } catch { - return { added: 0, clean: true, deleted: 0, modified: 0, total: 0 }; - } + return computeGitWorkingTreeStatus(dirPath); } /** @@ -1133,66 +1049,7 @@ export default class GitController extends ControllerModule { */ @IpcMethod() async getGitAheadBehind(dirPath: string): Promise { - const execFileAsync = promisify(execFile); - try { - await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], { - cwd: dirPath, - timeout: 10_000, - }); - } catch { - // swallow — fall through to compute against cached refs - } - try { - const { stdout: upstreamOut } = await execFileAsync( - 'git', - ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], - { cwd: dirPath, timeout: 5000 }, - ); - const upstream = upstreamOut.trim(); - if (!upstream) return { ahead: 0, behind: 0, hasUpstream: false }; - - const { stdout } = await execFileAsync( - 'git', - ['rev-list', '--left-right', '--count', `${upstream}...HEAD`], - { cwd: dirPath, timeout: 5000 }, - ); - const [behindStr, aheadStr] = stdout.trim().split(/\s+/); - const behind = Number.parseInt(behindStr ?? '0', 10) || 0; - const ahead = Number.parseInt(aheadStr ?? '0', 10) || 0; - - // `git push -u origin HEAD` always targets origin/, - // which may differ from upstream (the branched-off-canary case). - let pushTarget: string | undefined; - let pushTargetExists = false; - try { - const { stdout: branchOut } = await execFileAsync( - 'git', - ['symbolic-ref', '--short', 'HEAD'], - { cwd: dirPath, timeout: 5000 }, - ); - const branch = branchOut.trim(); - if (branch) { - pushTarget = `origin/${branch}`; - try { - await execFileAsync( - 'git', - ['rev-parse', '--verify', '--quiet', `refs/remotes/${pushTarget}`], - { cwd: dirPath, timeout: 5000 }, - ); - pushTargetExists = true; - } catch { - pushTargetExists = false; - } - } - } catch { - // detached HEAD — leave pushTarget undefined - } - - return { ahead, behind, hasUpstream: true, pushTarget, pushTargetExists, upstream }; - } catch { - // No upstream configured, detached HEAD, or git error — all treated as "no upstream" - return { ahead: 0, behind: 0, hasUpstream: false }; - } + return computeGitAheadBehind(dirPath); } /** diff --git a/apps/desktop/src/main/controllers/__tests__/ShellCommandCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/ShellCommandCtr.test.ts index 91c36d0fb8..1a312814b7 100644 --- a/apps/desktop/src/main/controllers/__tests__/ShellCommandCtr.test.ts +++ b/apps/desktop/src/main/controllers/__tests__/ShellCommandCtr.test.ts @@ -26,6 +26,7 @@ vi.mock('@/utils/logger', () => ({ // Mock child_process for the shared package vi.mock('node:child_process', () => ({ + execFile: vi.fn(), spawn: vi.fn(), })); diff --git a/apps/desktop/src/main/utils/__tests__/git.test.ts b/apps/desktop/src/main/utils/__tests__/git.test.ts index 17f866967c..6580b3c659 100644 --- a/apps/desktop/src/main/utils/__tests__/git.test.ts +++ b/apps/desktop/src/main/utils/__tests__/git.test.ts @@ -1,15 +1,11 @@ -import { readFile } from 'node:fs/promises'; +import { readdir, readFile } from 'node:fs/promises'; -import { readdir } from 'fs-extra'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { detectRepoType } from '../git'; vi.mock('node:fs/promises', () => ({ readFile: vi.fn(), -})); - -vi.mock('fs-extra', () => ({ readdir: vi.fn(), })); diff --git a/apps/desktop/src/main/utils/git.ts b/apps/desktop/src/main/utils/git.ts index 3b6543a599..917dca901f 100644 --- a/apps/desktop/src/main/utils/git.ts +++ b/apps/desktop/src/main/utils/git.ts @@ -1,71 +1,6 @@ -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; - -import { readdir } from 'fs-extra'; - /** - * Resolve the actual `.git` directory for a working tree. - * Supports both standard layouts and worktree pointer files (`.git` as a regular file - * containing `gitdir: `). + * Git repo-type / gitdir helpers. The implementations now live in + * `@lobechat/local-file-shell` so desktop, the device RPC, and the CLI share one + * copy; re-exported here to keep existing `@/utils/git` import sites stable. */ -export const resolveGitDir = async (dirPath: string): Promise => { - const gitPath = path.join(dirPath, '.git'); - try { - const content = await readFile(gitPath, 'utf8'); - const worktreeMatch = /^gitdir:\s*(\S.*)$/m.exec(content.trim()); - if (worktreeMatch) { - const resolved = worktreeMatch[1].trim(); - return path.isAbsolute(resolved) ? resolved : path.resolve(dirPath, resolved); - } - } catch { - // `.git` is a directory (EISDIR) or missing — fall through - } - try { - const stat = await readdir(gitPath); - if (stat.length > 0) return gitPath; - } catch { - return undefined; - } - return undefined; -}; - -/** - * Resolve the common git dir — where shared state like `config` and - * `packed-refs` lives. For linked worktrees, `resolveGitDir` returns - * `.git/worktrees//` which has its own `HEAD` but no `config`; - * the `commondir` pointer inside it resolves to the main repo's gitdir. - */ -export const resolveCommonGitDir = async (dirPath: string): Promise => { - const gitDir = await resolveGitDir(dirPath); - if (!gitDir) return undefined; - try { - const commondir = (await readFile(path.join(gitDir, 'commondir'), 'utf8')).trim(); - if (!commondir) return gitDir; - return path.isAbsolute(commondir) ? commondir : path.resolve(gitDir, commondir); - } catch { - return gitDir; - } -}; - -// Match `github.com` only in a remote-URL host position: preceded by `@`, `/`, -// or line start (covers `git@github.com:`, `https://github.com/`, -// `ssh://git@github.com/`, etc.) and followed by `:` or `/`. Avoids matching -// look-alikes like `evilgithub.com` or `github.com.attacker.com`. -const GITHUB_REMOTE_HOST_RE = /(?:^|[@/])github\.com[:/]/m; - -/** - * Classify a working tree as `git` (plain) / `github` (origin points at github.com) / - * `undefined` (not a git repo). Reads the shared gitdir's `config` so submodules and - * linked worktrees resolve the same as the main repo. - */ -export const detectRepoType = async (dirPath: string): Promise<'git' | 'github' | undefined> => { - const commonDir = await resolveCommonGitDir(dirPath); - if (!commonDir) return undefined; - try { - const config = await readFile(path.join(commonDir, 'config'), 'utf8'); - if (GITHUB_REMOTE_HOST_RE.test(config)) return 'github'; - return 'git'; - } catch { - return undefined; - } -}; +export { detectRepoType, resolveCommonGitDir, resolveGitDir } from '@lobechat/local-file-shell'; diff --git a/packages/local-file-shell/src/git/index.ts b/packages/local-file-shell/src/git/index.ts new file mode 100644 index 0000000000..3d77573d23 --- /dev/null +++ b/packages/local-file-shell/src/git/index.ts @@ -0,0 +1,3 @@ +export * from './info'; +export * from './repoType'; +export * from './types'; diff --git a/packages/local-file-shell/src/git/info.ts b/packages/local-file-shell/src/git/info.ts new file mode 100644 index 0000000000..5782fb776d --- /dev/null +++ b/packages/local-file-shell/src/git/info.ts @@ -0,0 +1,219 @@ +import { execFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { promisify } from 'node:util'; + +import { createLogger } from '../logger'; +import { resolveGitDir } from './repoType'; +import type { + DeviceGitInfo, + GitAheadBehind, + GitBranchInfo, + GitLinkedPullRequestResult, + GitWorkingTreeStatus, +} from './types'; + +const log = createLogger('local-file-shell:git'); +const execFileAsync = promisify(execFile); + +/** Current branch short name, or short SHA + `detached` for detached HEAD. */ +export const getGitBranch = async (dirPath: string): Promise => { + try { + const gitDir = await resolveGitDir(dirPath); + if (!gitDir) return {}; + + const head = (await readFile(`${gitDir}/HEAD`, 'utf8')).trim(); + const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(head); + if (refMatch) return { branch: refMatch[1] }; + // Detached HEAD — HEAD file contains the full sha + if (/^[\da-f]{40}$/i.test(head)) return { branch: head.slice(0, 7), detached: true }; + return {}; + } catch { + return {}; + } +}; + +/** + * Query `gh` CLI for an open pull request whose head branch matches `branch`. + * Returns `status: 'gh-missing'` when `gh` is unavailable / not authed. + */ +export const getLinkedPullRequest = async (payload: { + branch: string; + path: string; +}): Promise => { + const { path: dirPath, branch } = payload; + if (!branch) return { pullRequest: null, status: 'ok' }; + + try { + const { stdout } = await execFileAsync( + 'gh', + [ + 'pr', + 'list', + '--head', + branch, + '--state', + 'open', + '--limit', + '5', + '--json', + 'number,url,title,state', + ], + { cwd: dirPath, timeout: 8000 }, + ); + const parsed = JSON.parse(stdout.trim() || '[]') as Array<{ + number: number; + state: string; + title: string; + url: string; + }>; + if (parsed.length === 0) return { pullRequest: null, status: 'ok' }; + const [primary, ...rest] = parsed; + return { extraCount: rest.length, pullRequest: primary, status: 'ok' }; + } catch (error: any) { + const code = error?.code; + const stderr: string = error?.stderr ?? ''; + if (code === 'ENOENT') return { pullRequest: null, status: 'gh-missing' }; + if (/auth\s+login|not\s+logged\s+in|authentication/i.test(stderr)) { + return { pullRequest: null, status: 'gh-missing' }; + } + log.debug('[getLinkedPullRequest] failed', { branch, code, stderr }); + return { pullRequest: null, status: 'error' }; + } +}; + +/** Bucket dirty files into added / modified / deleted via `git status --porcelain -z`. */ +export const getGitWorkingTreeStatus = async (dirPath: string): Promise => { + try { + const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-u', '-z'], { + cwd: dirPath, + timeout: 5000, + }); + const tokens = stdout.split('\0'); + let added = 0; + let modified = 0; + let deleted = 0; + let i = 0; + while (i < tokens.length) { + const entry = tokens[i]; + i++; + if (entry.length < 2) continue; + const x = entry[0]; + const y = entry[1]; + // R/C entries carry an extra source-path token we must consume. + if (x === 'R' || x === 'C') i++; + if (x === '?' && y === '?') { + added++; + } else if (x === '!' && y === '!') { + // ignored — skip + } else if (x === 'D' || y === 'D') { + deleted++; + } else if (x === 'A' || y === 'A') { + added++; + } else { + modified++; + } + } + const total = added + modified + deleted; + return { added, clean: total === 0, deleted, modified, total }; + } catch { + return { added: 0, clean: true, deleted: 0, modified: 0, total: 0 }; + } +}; + +/** + * Count commits HEAD is ahead/behind its upstream. Does a best-effort `git fetch` + * first; swallows fetch failures (offline / no creds) and computes against cached + * refs. Returns `hasUpstream: false` when no upstream is configured. + */ +export const getGitAheadBehind = async (dirPath: string): Promise => { + try { + await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], { + cwd: dirPath, + timeout: 10_000, + }); + } catch { + // swallow — fall through to compute against cached refs + } + try { + const { stdout: upstreamOut } = await execFileAsync( + 'git', + ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], + { cwd: dirPath, timeout: 5000 }, + ); + const upstream = upstreamOut.trim(); + if (!upstream) return { ahead: 0, behind: 0, hasUpstream: false }; + + const { stdout } = await execFileAsync( + 'git', + ['rev-list', '--left-right', '--count', `${upstream}...HEAD`], + { cwd: dirPath, timeout: 5000 }, + ); + const [behindStr, aheadStr] = stdout.trim().split(/\s+/); + const behind = Number.parseInt(behindStr ?? '0', 10) || 0; + const ahead = Number.parseInt(aheadStr ?? '0', 10) || 0; + + // `git push -u origin HEAD` always targets origin/, + // which may differ from upstream (the branched-off-canary case). + let pushTarget: string | undefined; + let pushTargetExists = false; + try { + const { stdout: branchOut } = await execFileAsync( + 'git', + ['symbolic-ref', '--short', 'HEAD'], + { cwd: dirPath, timeout: 5000 }, + ); + const branch = branchOut.trim(); + if (branch) { + pushTarget = `origin/${branch}`; + try { + await execFileAsync( + 'git', + ['rev-parse', '--verify', '--quiet', `refs/remotes/${pushTarget}`], + { cwd: dirPath, timeout: 5000 }, + ); + pushTargetExists = true; + } catch { + pushTargetExists = false; + } + } + } catch { + // detached HEAD — leave pushTarget undefined + } + + return { ahead, behind, hasUpstream: true, pushTarget, pushTargetExists, upstream }; + } catch { + return { ahead: 0, behind: 0, hasUpstream: false }; + } +}; + +/** + * Aggregate git status (branch + linked PR + working tree + ahead/behind) into one + * payload. The single source behind the desktop display, the device `gitInfo` RPC, + * and the CLI. PR lookup runs only for a real branch on a GitHub remote. + */ +export const gitInfo = async (params: { + isGithub?: boolean; + scope: string; +}): Promise => { + const dirPath = params.scope; + const { branch, detached } = await getGitBranch(dirPath); + + let info: DeviceGitInfo['info'] = { branch, detached }; + if (branch && !detached && params.isGithub) { + const pr = await getLinkedPullRequest({ branch, path: dirPath }); + info = { + branch, + detached, + extraCount: pr.extraCount, + ghMissing: pr.status === 'gh-missing', + pullRequest: pr.pullRequest, + }; + } + + const [workingStatus, aheadBehind] = await Promise.all([ + getGitWorkingTreeStatus(dirPath), + getGitAheadBehind(dirPath), + ]); + + return { aheadBehind, info, workingStatus }; +}; diff --git a/packages/local-file-shell/src/git/repoType.ts b/packages/local-file-shell/src/git/repoType.ts new file mode 100644 index 0000000000..d32604c7d8 --- /dev/null +++ b/packages/local-file-shell/src/git/repoType.ts @@ -0,0 +1,68 @@ +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; + +/** + * Resolve the actual `.git` directory for a working tree. Supports both standard + * layouts and worktree pointer files (`.git` as a regular file containing + * `gitdir: `). + */ +export const resolveGitDir = async (dirPath: string): Promise => { + const gitPath = path.join(dirPath, '.git'); + try { + const content = await readFile(gitPath, 'utf8'); + const worktreeMatch = /^gitdir:\s*(\S.*)$/m.exec(content.trim()); + if (worktreeMatch) { + const resolved = worktreeMatch[1].trim(); + return path.isAbsolute(resolved) ? resolved : path.resolve(dirPath, resolved); + } + } catch { + // `.git` is a directory (EISDIR) or missing — fall through + } + try { + const entries = await readdir(gitPath); + if (entries.length > 0) return gitPath; + } catch { + return undefined; + } + return undefined; +}; + +/** + * Resolve the common git dir — where shared state like `config` and `packed-refs` + * lives. For linked worktrees, `resolveGitDir` returns `.git/worktrees//` + * which has its own `HEAD` but no `config`; the `commondir` pointer inside it + * resolves to the main repo's gitdir. + */ +export const resolveCommonGitDir = async (dirPath: string): Promise => { + const gitDir = await resolveGitDir(dirPath); + if (!gitDir) return undefined; + try { + const commondir = (await readFile(path.join(gitDir, 'commondir'), 'utf8')).trim(); + if (!commondir) return gitDir; + return path.isAbsolute(commondir) ? commondir : path.resolve(gitDir, commondir); + } catch { + return gitDir; + } +}; + +// Match `github.com` only in a remote-URL host position: preceded by `@`, `/`, or +// line start (covers `git@github.com:`, `https://github.com/`, `ssh://git@github.com/`) +// and followed by `:` or `/`. Avoids matching look-alikes like `evilgithub.com`. +const GITHUB_REMOTE_HOST_RE = /(?:^|[@/])github\.com[:/]/m; + +/** + * Classify a working tree as `git` (plain) / `github` (origin points at github.com) + * / `undefined` (not a git repo). Reads the shared gitdir's `config` so submodules + * and linked worktrees resolve the same as the main repo. + */ +export const detectRepoType = async (dirPath: string): Promise<'git' | 'github' | undefined> => { + const commonDir = await resolveCommonGitDir(dirPath); + if (!commonDir) return undefined; + try { + const config = await readFile(path.join(commonDir, 'config'), 'utf8'); + if (GITHUB_REMOTE_HOST_RE.test(config)) return 'github'; + return 'git'; + } catch { + return undefined; + } +}; diff --git a/packages/local-file-shell/src/git/types.ts b/packages/local-file-shell/src/git/types.ts new file mode 100644 index 0000000000..9f673fdc0c --- /dev/null +++ b/packages/local-file-shell/src/git/types.ts @@ -0,0 +1,55 @@ +/** Branch short name (or short SHA when detached). */ +export interface GitBranchInfo { + branch?: string; + detached?: boolean; +} + +export interface GitLinkedPullRequest { + number: number; + state: string; + title: string; + url: string; +} + +export interface GitLinkedPullRequestResult { + /** Additional open PRs targeting the same head branch, beyond the primary one. */ + extraCount?: number; + /** Null when no open PR is linked to the branch. */ + pullRequest: GitLinkedPullRequest | null; + /** 'ok' — succeeded; 'gh-missing' — gh CLI unavailable / not authed; 'error' — other. */ + status: 'ok' | 'gh-missing' | 'error'; +} + +export interface GitWorkingTreeStatus { + added: number; + clean: boolean; + deleted: number; + modified: number; + total: number; +} + +export interface GitAheadBehind { + ahead: number; + behind: number; + hasUpstream: boolean; + pushTarget?: string; + pushTargetExists?: boolean; + upstream?: string; +} + +/** + * Aggregate git status for a working directory — the single payload behind both + * the desktop git display and the device `gitInfo` RPC (and CLI). Mirrors the + * three renderer hooks (`useGitInfo` / `useWorkingTreeStatus` / `useGitAheadBehind`). + */ +export interface DeviceGitInfo { + aheadBehind: GitAheadBehind; + info: { + branch?: string; + detached?: boolean; + extraCount?: number; + ghMissing?: boolean; + pullRequest?: GitLinkedPullRequest | null; + }; + workingStatus: GitWorkingTreeStatus; +} diff --git a/packages/local-file-shell/src/index.ts b/packages/local-file-shell/src/index.ts index 35b077c45f..45da6b7529 100644 --- a/packages/local-file-shell/src/index.ts +++ b/packages/local-file-shell/src/index.ts @@ -1,6 +1,7 @@ export * from './contentSearch'; export * from './file'; export * from './fileSearch'; +export * from './git'; export { createLogger, type Logger, type LoggerFactory, setLoggerFactory } from './logger'; export * from './shell'; export type { ToolCategory, ToolDetector } from './toolDetector'; diff --git a/packages/types/src/agent/agencyConfig.ts b/packages/types/src/agent/agencyConfig.ts index 950e6af643..46ce3a8dcc 100644 --- a/packages/types/src/agent/agencyConfig.ts +++ b/packages/types/src/agent/agencyConfig.ts @@ -75,4 +75,18 @@ export interface LobeAgentAgencyConfig { * any `verifyCriteriaIds` — into its check plan. References `verify_rubrics.id`. */ verifyRubricId?: string; + /** + * Per-device working directory chosen for this agent. Key = `deviceId` (the + * local machine uses its own gateway deviceId, so local and remote share one + * model). This is the **agent-level** cwd in the resolution precedence: + * + * `topic.metadata.workingDirectory` + * > `workingDirByDevice[targetDeviceId]` + * > `device.defaultCwd` + * + * Keyed per device so switching the bound device never resolves a path that + * only exists on another machine. Persisted (server-synced) so the choice + * follows the user across sessions / ends. + */ + workingDirByDevice?: Record; } diff --git a/packages/types/src/agentExecution/index.ts b/packages/types/src/agentExecution/index.ts index f579b80e7f..43b3e1f9c5 100644 --- a/packages/types/src/agentExecution/index.ts +++ b/packages/types/src/agentExecution/index.ts @@ -146,6 +146,40 @@ export interface WorkspaceInitResult { skills: ProjectSkillMeta[]; } +/** + * Git status of a device's working directory, returned by the `gitInfo` device + * RPC so a remote device (or web client) can render branch / file changes / PR + * the same way the local desktop does. Field shapes mirror the desktop git + * service so the UI consumes both paths interchangeably. + */ +export interface DeviceGitInfo { + /** Commit divergence vs the upstream tracking ref. */ + aheadBehind: { + ahead: number; + behind: number; + hasUpstream: boolean; + pushTarget?: string; + pushTargetExists?: boolean; + upstream?: string; + }; + /** Branch name + linked GitHub pull request (when the repo is a GitHub remote). */ + info: { + branch?: string; + detached?: boolean; + extraCount?: number; + ghMissing?: boolean; + pullRequest?: { number: number; state: string; title: string; url: string } | null; + }; + /** Working-tree dirty-file counts. */ + workingStatus: { + added: number; + clean: boolean; + deleted: number; + modified: number; + total: number; + }; +} + /** * Parameters for execAgent - execute a single Agent * Either agentId or slug must be provided diff --git a/src/server/routers/lambda/device.ts b/src/server/routers/lambda/device.ts index 30dd19d88d..8341ba210f 100644 --- a/src/server/routers/lambda/device.ts +++ b/src/server/routers/lambda/device.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; import { DeviceModel } from '@/database/models/device'; import { authedProcedure, router } from '@/libs/trpc/lambda'; import { serverDatabase } from '@/libs/trpc/lambda/middleware'; -import { deviceGateway } from '@/server/services/toolExecution/deviceGateway'; +import { deviceGateway } from '@/server/services/deviceGateway'; import { preserveWorkspaceCache } from './deviceWorkingDirs'; @@ -76,6 +76,30 @@ export const deviceRouter = router({ } }), + /** + * Git status (branch / file changes / linked PR) for a directory on a remote + * device, fetched via the device's `gitInfo` RPC. Lets the UI render a remote + * device's git the same as the local desktop. Returns `null` when offline / + * the directory isn't a git repo. + */ + gitInfo: deviceProcedure + .input( + z.object({ + deviceId: z.string(), + isGithub: z.boolean().optional(), + scope: z.string(), + }), + ) + .query(async ({ ctx, input }) => { + const result = await deviceGateway.gitInfo( + ctx.userId, + input.deviceId, + input.scope, + input.isGithub, + ); + return result ?? null; + }), + /** * Fetch the agent profile (title, description, avatar) from the platform * installed on the given device. Used to pre-fill the creation modal. diff --git a/src/server/services/aiAgent/__tests__/execAgent.builtinRuntime.test.ts b/src/server/services/aiAgent/__tests__/execAgent.builtinRuntime.test.ts index 522fcd26d8..b6648905d1 100644 --- a/src/server/services/aiAgent/__tests__/execAgent.builtinRuntime.test.ts +++ b/src/server/services/aiAgent/__tests__/execAgent.builtinRuntime.test.ts @@ -162,7 +162,7 @@ vi.mock('@/server/modules/Mecha', () => ({ serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]), })); -vi.mock('@/server/services/toolExecution/deviceGateway', () => ({ +vi.mock('@/server/services/deviceGateway', () => ({ deviceGateway: { isConfigured: false, queryDeviceList: vi.fn().mockResolvedValue([]), diff --git a/src/server/services/aiAgent/__tests__/execAgent.device.test.ts b/src/server/services/aiAgent/__tests__/execAgent.device.test.ts index 7f1633dbdb..a1ea632779 100644 --- a/src/server/services/aiAgent/__tests__/execAgent.device.test.ts +++ b/src/server/services/aiAgent/__tests__/execAgent.device.test.ts @@ -118,7 +118,7 @@ vi.mock('@/server/modules/Mecha', () => ({ serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]), })); -vi.mock('@/server/services/toolExecution/deviceGateway', () => ({ +vi.mock('@/server/services/deviceGateway', () => ({ deviceGateway: mockDeviceProxy, })); diff --git a/src/server/services/aiAgent/__tests__/execAgent.deviceToolPipeline.test.ts b/src/server/services/aiAgent/__tests__/execAgent.deviceToolPipeline.test.ts index 0e054efdbe..865ada5bd1 100644 --- a/src/server/services/aiAgent/__tests__/execAgent.deviceToolPipeline.test.ts +++ b/src/server/services/aiAgent/__tests__/execAgent.deviceToolPipeline.test.ts @@ -126,7 +126,7 @@ vi.mock('@/server/modules/Mecha', () => { }; }); -vi.mock('@/server/services/toolExecution/deviceGateway', () => ({ +vi.mock('@/server/services/deviceGateway', () => ({ deviceGateway: { get isConfigured() { // Will be overridden per-test via vi.spyOn or re-mock @@ -209,7 +209,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => { describe('deviceContext forwarded to createServerAgentToolsEngine', () => { it('should pass deviceContext when gateway is configured', async () => { // Override deviceGateway.isConfigured - const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway'); + const { deviceGateway } = await import('@/server/services/deviceGateway'); vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(true); mockQueryDeviceList.mockResolvedValue([ { deviceId: 'dev-1', deviceName: 'My PC', platform: 'win32' }, @@ -230,7 +230,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => { }); it('should not pass deviceContext when gateway is not configured', async () => { - const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway'); + const { deviceGateway } = await import('@/server/services/deviceGateway'); vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(false); mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig()); @@ -245,7 +245,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => { describe('RemoteDevice systemRole override', () => { it('should override RemoteDevice systemRole with dynamic prompt when enabled by ToolsEngine', async () => { - const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway'); + const { deviceGateway } = await import('@/server/services/deviceGateway'); vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(true); mockQueryDeviceList.mockResolvedValue([ { deviceId: 'dev-1', deviceName: 'My PC', platform: 'win32' }, @@ -279,7 +279,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => { }); it('should NOT have RemoteDevice in manifestMap when gateway is not configured', async () => { - const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway'); + const { deviceGateway } = await import('@/server/services/deviceGateway'); vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(false); // ToolsEngine returns empty manifestMap (RemoteDevice disabled by enableChecker) @@ -301,7 +301,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => { describe('toolExecutorMap gating on gatewayConfigured (regression for #13769)', () => { it('should mark local-system as client when gateway is NOT configured (standalone Electron)', async () => { - const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway'); + const { deviceGateway } = await import('@/server/services/deviceGateway'); vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(false); mockGetEnabledPluginManifests.mockReturnValue( @@ -316,7 +316,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => { }); it('should NOT mark local-system as client when gateway IS configured (cloud)', async () => { - const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway'); + const { deviceGateway } = await import('@/server/services/deviceGateway'); vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(true); mockQueryDeviceList.mockResolvedValue([ { deviceId: 'dev-1', deviceName: 'My PC', platform: 'win32' }, @@ -348,7 +348,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => { mockGetEnabledPluginManifests.mockReturnValue(new Map([['my-stdio-mcp', stdioManifest]])); mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig({ plugins: ['my-stdio-mcp'] })); - const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway'); + const { deviceGateway } = await import('@/server/services/deviceGateway'); // Gateway NOT configured → should mark as client vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(false); @@ -374,7 +374,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => { // Remote Device proxy to the device registered with the gateway, never // back to the caller. (The Phase 6.4 clientRuntime=desktop // short-circuit that bypassed this gate was removed.) - const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway'); + const { deviceGateway } = await import('@/server/services/deviceGateway'); vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(true); mockQueryDeviceList.mockResolvedValue([ { deviceId: 'dev-1', deviceName: 'Remote VM', platform: 'linux' }, @@ -402,7 +402,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => { meta: { title: 'Stdio' }, }; - const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway'); + const { deviceGateway } = await import('@/server/services/deviceGateway'); vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(true); mockQueryDeviceList.mockResolvedValue([ { deviceId: 'dev-1', deviceName: 'Remote VM', platform: 'linux' }, diff --git a/src/server/services/aiAgent/__tests__/execAgent.disableTools.test.ts b/src/server/services/aiAgent/__tests__/execAgent.disableTools.test.ts index 3cf8442095..57eacb9c77 100644 --- a/src/server/services/aiAgent/__tests__/execAgent.disableTools.test.ts +++ b/src/server/services/aiAgent/__tests__/execAgent.disableTools.test.ts @@ -114,7 +114,7 @@ vi.mock('@/server/modules/Mecha', () => ({ serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]), })); -vi.mock('@/server/services/toolExecution/deviceGateway', () => ({ +vi.mock('@/server/services/deviceGateway', () => ({ deviceGateway: { isConfigured: false, queryDeviceList: vi.fn().mockResolvedValue([]), diff --git a/src/server/services/aiAgent/__tests__/execAgent.files.test.ts b/src/server/services/aiAgent/__tests__/execAgent.files.test.ts index 9aa5b007fe..df4f0cbd24 100644 --- a/src/server/services/aiAgent/__tests__/execAgent.files.test.ts +++ b/src/server/services/aiAgent/__tests__/execAgent.files.test.ts @@ -137,7 +137,7 @@ vi.mock('@/server/modules/Mecha', () => ({ serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]), })); -vi.mock('@/server/services/toolExecution/deviceGateway', () => ({ +vi.mock('@/server/services/deviceGateway', () => ({ deviceGateway: { isConfigured: false, queryDeviceList: vi.fn().mockResolvedValue([]), diff --git a/src/server/services/aiAgent/__tests__/execAgent.headlessDefault.test.ts b/src/server/services/aiAgent/__tests__/execAgent.headlessDefault.test.ts index 721e9e7780..213c53145f 100644 --- a/src/server/services/aiAgent/__tests__/execAgent.headlessDefault.test.ts +++ b/src/server/services/aiAgent/__tests__/execAgent.headlessDefault.test.ts @@ -88,7 +88,7 @@ vi.mock('@/server/modules/Mecha', () => ({ serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]), })); -vi.mock('@/server/services/toolExecution/deviceGateway', () => ({ +vi.mock('@/server/services/deviceGateway', () => ({ deviceGateway: { isConfigured: false, queryDeviceList: vi.fn().mockResolvedValue([]), diff --git a/src/server/services/aiAgent/__tests__/execAgent.modelOverride.test.ts b/src/server/services/aiAgent/__tests__/execAgent.modelOverride.test.ts index 93096d862f..c0a8cefbf6 100644 --- a/src/server/services/aiAgent/__tests__/execAgent.modelOverride.test.ts +++ b/src/server/services/aiAgent/__tests__/execAgent.modelOverride.test.ts @@ -88,7 +88,7 @@ vi.mock('@/server/modules/Mecha', () => ({ serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]), })); -vi.mock('@/server/services/toolExecution/deviceGateway', () => ({ +vi.mock('@/server/services/deviceGateway', () => ({ deviceGateway: { isConfigured: false, queryDeviceList: vi.fn().mockResolvedValue([]), diff --git a/src/server/services/aiAgent/__tests__/execAgent.resume.test.ts b/src/server/services/aiAgent/__tests__/execAgent.resume.test.ts index 637f438753..20b90af64b 100644 --- a/src/server/services/aiAgent/__tests__/execAgent.resume.test.ts +++ b/src/server/services/aiAgent/__tests__/execAgent.resume.test.ts @@ -110,7 +110,7 @@ vi.mock('@/server/modules/Mecha', () => ({ }), })); -vi.mock('@/server/services/toolExecution/deviceGateway', () => ({ +vi.mock('@/server/services/deviceGateway', () => ({ deviceGateway: { isConfigured: false, queryDeviceList: vi.fn().mockResolvedValue([]), diff --git a/src/server/services/aiAgent/__tests__/execAgent.resumeApproval.test.ts b/src/server/services/aiAgent/__tests__/execAgent.resumeApproval.test.ts index 449ff02aa3..8341dd5686 100644 --- a/src/server/services/aiAgent/__tests__/execAgent.resumeApproval.test.ts +++ b/src/server/services/aiAgent/__tests__/execAgent.resumeApproval.test.ts @@ -118,7 +118,7 @@ vi.mock('@/server/modules/Mecha', () => ({ }), })); -vi.mock('@/server/services/toolExecution/deviceGateway', () => ({ +vi.mock('@/server/services/deviceGateway', () => ({ deviceGateway: { isConfigured: false, queryDeviceList: vi.fn().mockResolvedValue([]) }, })); diff --git a/src/server/services/aiAgent/__tests__/execAgent.threadId.test.ts b/src/server/services/aiAgent/__tests__/execAgent.threadId.test.ts index 0e8ed31293..6cb9826c91 100644 --- a/src/server/services/aiAgent/__tests__/execAgent.threadId.test.ts +++ b/src/server/services/aiAgent/__tests__/execAgent.threadId.test.ts @@ -124,7 +124,7 @@ vi.mock('@/server/modules/Mecha', () => ({ })); // Mock deviceGateway -vi.mock('@/server/services/toolExecution/deviceGateway', () => ({ +vi.mock('@/server/services/deviceGateway', () => ({ deviceGateway: { isConfigured: false, queryDeviceList: vi.fn().mockResolvedValue([]), diff --git a/src/server/services/aiAgent/__tests__/execAgent.topicHistory.test.ts b/src/server/services/aiAgent/__tests__/execAgent.topicHistory.test.ts index 9b2c4a07c6..cb7ece40c1 100644 --- a/src/server/services/aiAgent/__tests__/execAgent.topicHistory.test.ts +++ b/src/server/services/aiAgent/__tests__/execAgent.topicHistory.test.ts @@ -110,7 +110,7 @@ vi.mock('@/server/services/file', () => ({ })), })); -vi.mock('@/server/services/toolExecution/deviceGateway', () => ({ +vi.mock('@/server/services/deviceGateway', () => ({ deviceGateway: { isConfigured: false, queryDeviceList: vi.fn().mockResolvedValue([]), diff --git a/src/server/services/aiAgent/index.ts b/src/server/services/aiAgent/index.ts index 2361004cf0..2d02a72198 100644 --- a/src/server/services/aiAgent/index.ts +++ b/src/server/services/aiAgent/index.ts @@ -81,6 +81,7 @@ import { resolveAgentSelfIterationCapability, } from '@/server/services/agentSignal/featureGate'; import { shouldSuppressSignal } from '@/server/services/agentSignal/suppressSignal'; +import { deviceGateway } from '@/server/services/deviceGateway'; import { DocumentService } from '@/server/services/document'; import { FileService } from '@/server/services/file'; import { resolveAttachmentsByFileIds } from '@/server/services/file/resolveAttachments'; @@ -88,7 +89,6 @@ import { HeterogeneousAgentService } from '@/server/services/heterogeneousAgent' import type { ConversationHistoryEntry } from '@/server/services/heterogeneousAgent/cloudHeteroContext'; import { KlavisService } from '@/server/services/klavis'; import { MarketService } from '@/server/services/market'; -import { deviceGateway } from '@/server/services/toolExecution/deviceGateway'; import { markdownToTxt } from '@/utils/markdownToTxt'; import { resolveDeviceAccessPolicy } from './deviceAccessPolicy'; diff --git a/src/server/services/bot/platforms/imessage/client.test.ts b/src/server/services/bot/platforms/imessage/client.test.ts index 38c8061647..9abe2cc2d3 100644 --- a/src/server/services/bot/platforms/imessage/client.test.ts +++ b/src/server/services/bot/platforms/imessage/client.test.ts @@ -4,7 +4,7 @@ import { ImessageClientFactory } from './client'; const mockExecuteMessageApi = vi.hoisted(() => vi.fn()); -vi.mock('@/server/services/toolExecution/deviceGateway', () => ({ +vi.mock('@/server/services/deviceGateway', () => ({ deviceGateway: { executeMessageApi: mockExecuteMessageApi, }, diff --git a/src/server/services/bot/platforms/imessage/desktopBridge.ts b/src/server/services/bot/platforms/imessage/desktopBridge.ts index f490c2667f..ef3a40ce35 100644 --- a/src/server/services/bot/platforms/imessage/desktopBridge.ts +++ b/src/server/services/bot/platforms/imessage/desktopBridge.ts @@ -7,7 +7,7 @@ import type { BlueBubblesSendOptions, } from '@lobechat/chat-adapter-imessage'; -import { deviceGateway } from '@/server/services/toolExecution/deviceGateway'; +import { deviceGateway } from '@/server/services/deviceGateway'; const IMESSAGE_MESSAGE_API_TIMEOUT_MS = 60_000; diff --git a/src/server/services/toolExecution/__tests__/deviceGateway.test.ts b/src/server/services/deviceGateway/__tests__/index.test.ts similarity index 99% rename from src/server/services/toolExecution/__tests__/deviceGateway.test.ts rename to src/server/services/deviceGateway/__tests__/index.test.ts index 8e280abd93..0c3be9efe1 100644 --- a/src/server/services/toolExecution/__tests__/deviceGateway.test.ts +++ b/src/server/services/deviceGateway/__tests__/index.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; // Import after mocks are set up -import { DeviceGateway } from '../deviceGateway'; +import { DeviceGateway } from '../index'; const mockEnv = vi.hoisted(() => ({ DEVICE_GATEWAY_SERVICE_TOKEN: undefined as string | undefined, diff --git a/src/server/services/toolExecution/deviceGateway.ts b/src/server/services/deviceGateway/index.ts similarity index 89% rename from src/server/services/toolExecution/deviceGateway.ts rename to src/server/services/deviceGateway/index.ts index 08241bf2da..6b5ccf4f73 100644 --- a/src/server/services/toolExecution/deviceGateway.ts +++ b/src/server/services/deviceGateway/index.ts @@ -8,7 +8,7 @@ import { type GatewayMcpStdioParams, } from '@lobechat/device-gateway-client'; import type { HeterogeneousAgentType } from '@lobechat/heterogeneous-agents'; -import type { ProjectSkillMeta, WorkspaceInitResult } from '@lobechat/types'; +import type { DeviceGitInfo, ProjectSkillMeta, WorkspaceInitResult } from '@lobechat/types'; import debug from 'debug'; import { gatewayEnv } from '@/envs/gateway'; @@ -127,6 +127,39 @@ export class DeviceGateway { } } + /** + * Fetch git status (branch / file changes / PR) for a directory on a remote + * device, via the same generic `invokeRpc` channel as `initWorkspace`. Lets + * the UI render a remote device's git the same as the local desktop. + */ + async gitInfo( + userId: string, + deviceId: string, + scope: string, + isGithub = false, + timeout = 15_000, + ): Promise { + const client = this.getClient(); + if (!client) return undefined; + + try { + const result = await client.invokeRpc( + { deviceId, timeout, userId }, + { method: 'gitInfo', params: { isGithub, scope } }, + ); + + if (!result.success || !result.data) { + log('gitInfo: failed for deviceId=%s — %s', deviceId, result.error); + return undefined; + } + + return result.data; + } catch (error) { + log('gitInfo: error for deviceId=%s — %O', deviceId, error); + return undefined; + } + } + async dispatchAgentRun(params: { agentType: HeterogeneousAgentType; cwd?: string; diff --git a/src/server/services/toolExecution/index.ts b/src/server/services/toolExecution/index.ts index b4e72f89e1..cea571185c 100644 --- a/src/server/services/toolExecution/index.ts +++ b/src/server/services/toolExecution/index.ts @@ -8,6 +8,7 @@ import { buildBlockedToolResponse, getConnectorToolPermission, } from '@/libs/mcp/connectorPermissionCheck'; +import { deviceGateway } from '@/server/services/deviceGateway'; import { contentBlocksToString } from '@/server/services/mcp/contentProcessor'; import { DEFAULT_TOOL_RESULT_MAX_LENGTH, @@ -17,7 +18,6 @@ import { import { DiscoverService } from '../discover'; import { type MCPService } from '../mcp'; import { type BuiltinToolsExecutor } from './builtin'; -import { deviceGateway } from './deviceGateway'; import { classifyToolError } from './errorClassification'; import { type ToolExecutionContext, diff --git a/src/server/services/toolExecution/serverRuntimes/__tests__/localSystem.test.ts b/src/server/services/toolExecution/serverRuntimes/__tests__/localSystem.test.ts index 59ed4ed16b..a25e1e4baa 100644 --- a/src/server/services/toolExecution/serverRuntimes/__tests__/localSystem.test.ts +++ b/src/server/services/toolExecution/serverRuntimes/__tests__/localSystem.test.ts @@ -5,7 +5,7 @@ import { type ToolExecutionContext } from '../../types'; // Mock deviceGateway const mockExecuteToolCall = vi.fn(); -vi.mock('../../deviceGateway', () => ({ +vi.mock('@/server/services/deviceGateway', () => ({ deviceGateway: { executeToolCall: (...args: any[]) => mockExecuteToolCall(...args), }, diff --git a/src/server/services/toolExecution/serverRuntimes/__tests__/remoteDevice.test.ts b/src/server/services/toolExecution/serverRuntimes/__tests__/remoteDevice.test.ts index 8c55e1bbd2..c289715d23 100644 --- a/src/server/services/toolExecution/serverRuntimes/__tests__/remoteDevice.test.ts +++ b/src/server/services/toolExecution/serverRuntimes/__tests__/remoteDevice.test.ts @@ -8,7 +8,7 @@ import { type ToolExecutionContext } from '../../types'; // Mock deviceGateway const mockQueryDeviceList = vi.fn(); -vi.mock('../../deviceGateway', () => ({ +vi.mock('@/server/services/deviceGateway', () => ({ deviceGateway: { queryDeviceList: (...args: any[]) => mockQueryDeviceList(...args), }, diff --git a/src/server/services/toolExecution/serverRuntimes/localSystem.ts b/src/server/services/toolExecution/serverRuntimes/localSystem.ts index 51efc24906..96ac637c2b 100644 --- a/src/server/services/toolExecution/serverRuntimes/localSystem.ts +++ b/src/server/services/toolExecution/serverRuntimes/localSystem.ts @@ -1,6 +1,7 @@ import { LocalSystemIdentifier, LocalSystemManifest } from '@lobechat/builtin-tool-local-system'; -import { deviceGateway } from '../deviceGateway'; +import { deviceGateway } from '@/server/services/deviceGateway'; + import { type ServerRuntimeRegistration } from './types'; export const localSystemRuntime: ServerRuntimeRegistration = { diff --git a/src/server/services/toolExecution/serverRuntimes/remoteDevice.ts b/src/server/services/toolExecution/serverRuntimes/remoteDevice.ts index 548aaaf51c..96cdf4897c 100644 --- a/src/server/services/toolExecution/serverRuntimes/remoteDevice.ts +++ b/src/server/services/toolExecution/serverRuntimes/remoteDevice.ts @@ -3,7 +3,8 @@ import { RemoteDeviceIdentifier, } from '@lobechat/builtin-tool-remote-device'; -import { deviceGateway } from '../deviceGateway'; +import { deviceGateway } from '@/server/services/deviceGateway'; + import { type ServerRuntimeRegistration } from './types'; export const remoteDeviceRuntime: ServerRuntimeRegistration = { diff --git a/src/server/services/toolExecution/serverRuntimes/skills.ts b/src/server/services/toolExecution/serverRuntimes/skills.ts index 3ecf07ccac..0c203bd519 100644 --- a/src/server/services/toolExecution/serverRuntimes/skills.ts +++ b/src/server/services/toolExecution/serverRuntimes/skills.ts @@ -21,13 +21,13 @@ import { FileModel } from '@/database/models/file'; import { UserModel } from '@/database/models/user'; import { filterBuiltinSkills } from '@/helpers/skillFilters'; import { AgentDocumentsService } from '@/server/services/agentDocuments'; +import { deviceGateway } from '@/server/services/deviceGateway'; import { FileService } from '@/server/services/file'; import { MarketService } from '@/server/services/market'; import { createSandboxService, normalizeSandboxCommandResult } from '@/server/services/sandbox'; import { SkillResourceService } from '@/server/services/skill/resource'; import { preprocessLhCommand } from '@/server/services/toolExecution/preprocessLhCommand'; -import { deviceGateway } from '../deviceGateway'; import { type ServerRuntimeRegistration } from './types'; const log = debug('lobe-server:skills-runtime');