mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 11:40:07 +00:00
Compare commits
10 Commits
main
...
feat/cloudCCUI
| Author | SHA1 | Date | |
|---|---|---|---|
| ffc439fcce | |||
| 176e3490c8 | |||
| f1bc10efb2 | |||
| 33e1fa7b31 | |||
| 40be523bbd | |||
| e6608399f7 | |||
| d5b74d31b9 | |||
| f59e32ff5e | |||
| 4823d22560 | |||
| 0405676c4a |
@@ -184,6 +184,10 @@
|
||||
"groupWizard.searchTemplates": "Search templates...",
|
||||
"groupWizard.title": "Create Group",
|
||||
"groupWizard.useTemplate": "Use Template",
|
||||
"heteroAgent.cloudRepo.multiSelected": "{{count}} repos selected",
|
||||
"heteroAgent.cloudRepo.noRepos": "No repositories configured. Add them in agent settings.",
|
||||
"heteroAgent.cloudRepo.notSet": "No repo selected",
|
||||
"heteroAgent.cloudRepo.sectionTitle": "Repositories",
|
||||
"heteroAgent.fullAccess.label": "Full access",
|
||||
"heteroAgent.fullAccess.tooltip": "Claude Code runs locally with full read/write access to the working directory. Switching permission modes is not available yet.",
|
||||
"heteroAgent.resumeReset.cwdChanged": "Working directory changed. Previous Claude Code session can only be resumed from its original directory, so a new conversation has started.",
|
||||
|
||||
@@ -291,9 +291,26 @@
|
||||
"heterogeneousStatus.auth.api": "API",
|
||||
"heterogeneousStatus.auth.label": "Auth Method",
|
||||
"heterogeneousStatus.auth.subscription": "Subscription",
|
||||
"heterogeneousStatus.cloud.githubDesc": "Select a GitHub credential to allow the sandbox to clone your private repositories.",
|
||||
"heterogeneousStatus.cloud.githubLabel": "GitHub Connection",
|
||||
"heterogeneousStatus.cloud.githubNoCreds": "No GitHub credentials found.",
|
||||
"heterogeneousStatus.cloud.githubPlaceholder": "Select a GitHub credential...",
|
||||
"heterogeneousStatus.cloud.manageCredentials": "Manage Credentials →",
|
||||
"heterogeneousStatus.cloud.repoAdd": "Add",
|
||||
"heterogeneousStatus.cloud.repoDesc": "Add repositories to the list. Switch the active one from the bottom bar in the chat view.",
|
||||
"heterogeneousStatus.cloud.repoLabel": "Repositories",
|
||||
"heterogeneousStatus.cloud.repoPlaceholder": "owner/repo or https://github.com/owner/repo",
|
||||
"heterogeneousStatus.cloud.tabLabel": "Cloud",
|
||||
"heterogeneousStatus.cloud.tokenCancel": "Cancel",
|
||||
"heterogeneousStatus.cloud.tokenChange": "Change",
|
||||
"heterogeneousStatus.cloud.tokenDesc": "Your Claude Code OAuth token. Saved securely to Credentials once submitted. Run `claude setup-token` in your terminal to generate one.",
|
||||
"heterogeneousStatus.cloud.tokenLabel": "Claude Code Token",
|
||||
"heterogeneousStatus.cloud.tokenPlaceholder": "Paste your OAuth token here",
|
||||
"heterogeneousStatus.cloud.tokenSave": "Save",
|
||||
"heterogeneousStatus.command.edit": "Edit command",
|
||||
"heterogeneousStatus.command.label": "Launch Command",
|
||||
"heterogeneousStatus.command.placeholder": "Command name or absolute path",
|
||||
"heterogeneousStatus.desktop.tabLabel": "Desktop",
|
||||
"heterogeneousStatus.detecting": "Detecting {{name}} CLI...",
|
||||
"heterogeneousStatus.plan.label": "Plan",
|
||||
"heterogeneousStatus.redetect": "Re-detect",
|
||||
|
||||
@@ -184,6 +184,10 @@
|
||||
"groupWizard.searchTemplates": "搜索模板…",
|
||||
"groupWizard.title": "创建群组",
|
||||
"groupWizard.useTemplate": "使用模板",
|
||||
"heteroAgent.cloudRepo.multiSelected": "已选 {{count}} 个仓库",
|
||||
"heteroAgent.cloudRepo.noRepos": "未配置仓库,请在助理设置中添加。",
|
||||
"heteroAgent.cloudRepo.notSet": "未选择仓库",
|
||||
"heteroAgent.cloudRepo.sectionTitle": "代码仓库",
|
||||
"heteroAgent.fullAccess.label": "完全访问权限",
|
||||
"heteroAgent.fullAccess.tooltip": "Claude Code 在本地运行,对工作目录拥有完全的读写权限。当前暂不支持切换权限模式。",
|
||||
"heteroAgent.resumeReset.cwdChanged": "工作目录已切换,之前的 Claude Code 会话只能在原目录下继续,已开始新对话。",
|
||||
|
||||
@@ -291,9 +291,26 @@
|
||||
"heterogeneousStatus.auth.api": "API",
|
||||
"heterogeneousStatus.auth.label": "认证方式",
|
||||
"heterogeneousStatus.auth.subscription": "订阅",
|
||||
"heterogeneousStatus.cloud.githubDesc": "选择 GitHub 凭证,让沙箱能够克隆你的私有仓库。",
|
||||
"heterogeneousStatus.cloud.githubLabel": "GitHub 连接",
|
||||
"heterogeneousStatus.cloud.githubNoCreds": "未找到 GitHub 凭证。",
|
||||
"heterogeneousStatus.cloud.githubPlaceholder": "选择 GitHub 凭证…",
|
||||
"heterogeneousStatus.cloud.manageCredentials": "管理凭证 →",
|
||||
"heterogeneousStatus.cloud.repoAdd": "添加",
|
||||
"heterogeneousStatus.cloud.repoDesc": "在此管理仓库列表,在对话视图底栏切换当前激活的仓库。",
|
||||
"heterogeneousStatus.cloud.repoLabel": "代码仓库",
|
||||
"heterogeneousStatus.cloud.repoPlaceholder": "owner/repo 或 https://github.com/owner/repo",
|
||||
"heterogeneousStatus.cloud.tabLabel": "云端",
|
||||
"heterogeneousStatus.cloud.tokenCancel": "取消",
|
||||
"heterogeneousStatus.cloud.tokenChange": "修改",
|
||||
"heterogeneousStatus.cloud.tokenDesc": "你的 Claude Code OAuth Token,提交后将安全保存到凭证管理。在终端运行 `claude setup-token` 即可获取。",
|
||||
"heterogeneousStatus.cloud.tokenLabel": "Claude Code Token",
|
||||
"heterogeneousStatus.cloud.tokenPlaceholder": "粘贴你的 OAuth Token",
|
||||
"heterogeneousStatus.cloud.tokenSave": "保存",
|
||||
"heterogeneousStatus.command.edit": "编辑指令",
|
||||
"heterogeneousStatus.command.label": "启动指令",
|
||||
"heterogeneousStatus.command.placeholder": "指令名称或绝对路径",
|
||||
"heterogeneousStatus.desktop.tabLabel": "桌面端",
|
||||
"heterogeneousStatus.detecting": "正在检测 {{name}} CLI…",
|
||||
"heterogeneousStatus.plan.label": "方案",
|
||||
"heterogeneousStatus.redetect": "重新检测",
|
||||
|
||||
@@ -10,6 +10,13 @@ export interface HeterogeneousProviderConfig {
|
||||
command?: string;
|
||||
/** Custom environment variables */
|
||||
env?: Record<string, string>;
|
||||
/**
|
||||
* Static context prepended to every user prompt before it reaches the agent CLI.
|
||||
* Use this to prime the agent with workspace conventions, rules, or instructions
|
||||
* that should apply to every conversation.
|
||||
* Combined with any runtime-generated context (e.g. cloned repo list).
|
||||
*/
|
||||
systemContext?: string;
|
||||
/** Agent runtime type */
|
||||
type: 'claude-code' | 'codex';
|
||||
}
|
||||
|
||||
@@ -11,6 +11,14 @@ export interface ExecAgentAppContext {
|
||||
documentId?: string | null;
|
||||
/** Group ID for group chat */
|
||||
groupId?: string | null;
|
||||
/**
|
||||
* Initial metadata to merge into the topic when a new topic is created for
|
||||
* this execution. Ignored when a topicId is already provided (existing topic).
|
||||
*/
|
||||
initialTopicMetadata?: {
|
||||
repos?: string[];
|
||||
workingDirectory?: string;
|
||||
};
|
||||
/** Scope identifier */
|
||||
scope?: string | null;
|
||||
/** Session ID */
|
||||
|
||||
@@ -105,6 +105,12 @@ export interface ChatTopicMetadata {
|
||||
onboardingFeedback?: OnboardingFeedbackEntry;
|
||||
onboardingSession?: OnboardingSessionSnapshot;
|
||||
provider?: string;
|
||||
/**
|
||||
* Web (cloud) only. Ordered list of GitHub repos selected for this topic.
|
||||
* Each repo will be cloned into the Gateway sandbox before execution.
|
||||
* `workingDirectory` is kept in sync with repos[0] (the primary repo).
|
||||
*/
|
||||
repos?: string[];
|
||||
/**
|
||||
* Currently running Gateway operation on this topic.
|
||||
* Set when agent execution starts, cleared when it completes/fails.
|
||||
@@ -119,10 +125,13 @@ export interface ChatTopicMetadata {
|
||||
userMemoryExtractRunState?: TopicUserMemoryExtractRunState;
|
||||
userMemoryExtractStatus?: 'pending' | 'completed' | 'failed';
|
||||
/**
|
||||
* Topic-level working directory (desktop only).
|
||||
* Topic-level working directory.
|
||||
* On desktop: local filesystem path for the CC session cwd.
|
||||
* On web (cloud): URL of the primary GitHub repo (first item of `repos`).
|
||||
* Priority is higher than Agent-level settings. Also serves as the
|
||||
* binding cwd for a CC session — written on first CC execution and
|
||||
* checked on subsequent turns to decide whether `--resume` is safe.
|
||||
* For sidebar grouping, topics are bucketed by this field (byProject mode).
|
||||
*/
|
||||
workingDirectory?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { Github } from '@lobehub/icons';
|
||||
import { Flexbox, Icon, Popover } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { CheckIcon, ChevronDownIcon, SquircleDashed } from 'lucide-react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { getPendingTopicRepos, setPendingTopicRepos } from '@/store/chat/pendingTopicRepos';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
button: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 2px;
|
||||
padding-inline: 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
checkIndicator: css`
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1.5px solid ${cssVar.colorBorder};
|
||||
border-radius: 4px;
|
||||
`,
|
||||
checkIndicatorChecked: css`
|
||||
border-color: ${cssVar.colorPrimary};
|
||||
color: #fff;
|
||||
background: ${cssVar.colorPrimary};
|
||||
`,
|
||||
repoItem: css`
|
||||
cursor: pointer;
|
||||
|
||||
padding-block: 6px;
|
||||
padding-inline: 8px;
|
||||
border-radius: ${cssVar.borderRadius};
|
||||
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
repoName: css`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
repoUrl: css`
|
||||
overflow: hidden;
|
||||
|
||||
font-size: 11px;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
scrollContainer: css`
|
||||
overflow-y: auto;
|
||||
max-height: 360px;
|
||||
`,
|
||||
sectionTitle: css`
|
||||
padding-block: 6px 2px;
|
||||
padding-inline: 8px;
|
||||
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: ${cssVar.colorTextQuaternary};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
`,
|
||||
}));
|
||||
|
||||
const getRepoName = (repo: string) => repo.split('/').findLast(Boolean) || repo;
|
||||
|
||||
interface CloudRepoSwitcherProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
const CloudRepoSwitcher = memo<CloudRepoSwitcherProps>(({ agentId }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const [open, setOpen] = useState(false);
|
||||
// Incremented to trigger re-renders when the module-singleton pending selection changes.
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
// Available repos configured on the agent
|
||||
const availableRepos: string[] = useAgentStore((s) => {
|
||||
const env = agentByIdSelectors.getAgencyConfigById(agentId)(s)?.heterogeneousProvider?.env;
|
||||
try {
|
||||
return JSON.parse(env?.GITHUB_REPOS ?? '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Repos persisted to the current topic (empty when no topic or none set)
|
||||
const topicRepos: string[] = useChatStore((s) => {
|
||||
const meta = topicSelectors.currentTopicMetadata(s);
|
||||
return meta?.repos ?? [];
|
||||
});
|
||||
|
||||
const activeTopicId = useChatStore((s) => s.activeTopicId);
|
||||
const updateTopicMetadata = useChatStore((s) => s.updateTopicMetadata);
|
||||
|
||||
const currentWorkingDirectory = useChatStore(
|
||||
(s) => topicSelectors.currentTopicMetadata(s)?.workingDirectory,
|
||||
);
|
||||
|
||||
const toggleRepo = useCallback(
|
||||
async (repo: string) => {
|
||||
if (!activeTopicId) {
|
||||
// No topic yet — buffer in the module singleton keyed by agentId.
|
||||
// gateway.ts will read and consume this when the first message creates a topic.
|
||||
const prev = getPendingTopicRepos(agentId);
|
||||
const isSelected = prev.includes(repo);
|
||||
const next = isSelected ? prev.filter((r) => r !== repo) : [...prev, repo];
|
||||
setPendingTopicRepos(agentId, next);
|
||||
forceUpdate((v) => v + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const isSelected = topicRepos.includes(repo);
|
||||
const nextRepos = isSelected ? topicRepos.filter((r) => r !== repo) : [...topicRepos, repo];
|
||||
|
||||
// Only set workingDirectory when it hasn't been assigned yet (first selection).
|
||||
// Once set, it stays fixed so the topic keeps its sidebar grouping.
|
||||
const patch: { repos: string[]; workingDirectory?: string } = { repos: nextRepos };
|
||||
if (!currentWorkingDirectory && nextRepos.length > 0) {
|
||||
patch.workingDirectory = nextRepos[0];
|
||||
}
|
||||
|
||||
await updateTopicMetadata(activeTopicId, patch);
|
||||
},
|
||||
[agentId, activeTopicId, currentWorkingDirectory, topicRepos, updateTopicMetadata],
|
||||
);
|
||||
|
||||
if (availableRepos.length === 0) return null;
|
||||
|
||||
// When a topic exists, show its persisted repos.
|
||||
// When no topic exists yet, show the pending selection from the module singleton.
|
||||
const displayRepos = activeTopicId ? topicRepos : getPendingTopicRepos(agentId);
|
||||
|
||||
// Button label
|
||||
const buttonLabel =
|
||||
displayRepos.length === 0
|
||||
? t('heteroAgent.cloudRepo.notSet')
|
||||
: displayRepos.length === 1
|
||||
? getRepoName(displayRepos[0])
|
||||
: t('heteroAgent.cloudRepo.multiSelected', { count: displayRepos.length });
|
||||
|
||||
const content = (
|
||||
<Flexbox gap={4} style={{ minWidth: 280 }}>
|
||||
<div className={styles.sectionTitle}>{t('heteroAgent.cloudRepo.sectionTitle')}</div>
|
||||
<div className={styles.scrollContainer}>
|
||||
{availableRepos.map((repo) => {
|
||||
const isChecked = displayRepos.includes(repo);
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align="center"
|
||||
className={styles.repoItem}
|
||||
gap={8}
|
||||
key={repo}
|
||||
onClick={() => toggleRepo(repo)}
|
||||
>
|
||||
<div
|
||||
className={`${styles.checkIndicator} ${isChecked ? styles.checkIndicatorChecked : ''}`}
|
||||
>
|
||||
{isChecked && <Icon icon={CheckIcon} size={12} />}
|
||||
</div>
|
||||
<Github size={16} style={{ color: cssVar.colorTextTertiary, flex: 'none' }} />
|
||||
<Flexbox flex={1} style={{ minWidth: 0 }}>
|
||||
<div className={styles.repoName}>{getRepoName(repo)}</div>
|
||||
<div className={styles.repoUrl}>{repo}</div>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
open={open}
|
||||
placement="bottomLeft"
|
||||
styles={{ content: { padding: 4 } }}
|
||||
trigger="click"
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<div className={styles.button}>
|
||||
{displayRepos.length > 0 ? <Github size={14} /> : <Icon icon={SquircleDashed} size={14} />}
|
||||
<span>{buttonLabel}</span>
|
||||
<Icon icon={ChevronDownIcon} size={12} />
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
CloudRepoSwitcher.displayName = 'CloudRepoSwitcher';
|
||||
|
||||
export default CloudRepoSwitcher;
|
||||
@@ -23,6 +23,7 @@ import { topicSelectors } from '@/store/chat/selectors';
|
||||
import { useAgentId } from '../hooks/useAgentId';
|
||||
import { useUpdateAgentConfig } from '../hooks/useUpdateAgentConfig';
|
||||
import ApprovalMode from './ApprovalMode';
|
||||
import CloudRepoSwitcher from './CloudRepoSwitcher';
|
||||
import GitStatus from './GitStatus';
|
||||
import { useRepoType } from './useRepoType';
|
||||
import WorkingDirectory from './WorkingDirectory';
|
||||
@@ -104,9 +105,10 @@ const RuntimeConfig = memo(() => {
|
||||
const [dirPopoverOpen, setDirPopoverOpen] = useState(false);
|
||||
const [modePopoverOpen, setModePopoverOpen] = useState(false);
|
||||
|
||||
const [isLoading, runtimeMode] = useAgentStore((s) => [
|
||||
const [isLoading, runtimeMode, isHeterogeneous] = useAgentStore((s) => [
|
||||
agentByIdSelectors.isAgentConfigLoadingById(agentId)(s),
|
||||
chatConfigByIdSelectors.getRuntimeModeById(agentId)(s),
|
||||
agentId ? agentByIdSelectors.isAgentHeterogeneousById(agentId)(s) : false,
|
||||
]);
|
||||
|
||||
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
|
||||
@@ -227,6 +229,13 @@ const RuntimeConfig = memo(() => {
|
||||
);
|
||||
|
||||
const rightContent = () => {
|
||||
// Web + heterogeneous agent always shows the cloud repo switcher,
|
||||
// regardless of the stored runtimeMode (which may be 'local' from desktop).
|
||||
if (!isDesktop && isHeterogeneous && agentId) {
|
||||
return <CloudRepoSwitcher agentId={agentId} />;
|
||||
}
|
||||
|
||||
// Desktop local mode: show working directory picker
|
||||
if (runtimeMode === 'local') {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -166,6 +166,10 @@ export default {
|
||||
'Claude Code sessions are pinned to a working directory. Switching will start a new session for this topic — chat messages stay, but the previous session context cannot be resumed.',
|
||||
'heteroAgent.switchCwd.ok': 'Switch and start new session',
|
||||
'heteroAgent.switchCwd.title': 'Switch working directory?',
|
||||
'heteroAgent.cloudRepo.sectionTitle': 'Repositories',
|
||||
'heteroAgent.cloudRepo.notSet': 'No repo selected',
|
||||
'heteroAgent.cloudRepo.noRepos': 'No repositories configured. Add them in agent settings.',
|
||||
'heteroAgent.cloudRepo.multiSelected': '{{count}} repos selected',
|
||||
'hideForYou':
|
||||
"Direct message content is hidden. Please enable 'Show Direct Message Content' in settings to view.",
|
||||
'history.title': 'The Agent will keep only the latest {{count}} messages.',
|
||||
|
||||
@@ -219,6 +219,31 @@ export default {
|
||||
'heterogeneousStatus.plan.label': 'Plan',
|
||||
'heterogeneousStatus.redetect': 'Re-detect',
|
||||
'heterogeneousStatus.unavailable': '{{name}} CLI not found. Please install or configure it.',
|
||||
|
||||
// Heterogeneous agent — Cloud tab (web environment config)
|
||||
'heterogeneousStatus.cloud.tabLabel': 'Cloud',
|
||||
'heterogeneousStatus.cloud.tokenLabel': 'Claude Code Token',
|
||||
'heterogeneousStatus.cloud.tokenDesc':
|
||||
'Your Claude Code OAuth token. Saved securely to Credentials once submitted. Run `claude setup-token` in your terminal to generate one.',
|
||||
'heterogeneousStatus.cloud.tokenPlaceholder': 'Paste your OAuth token here',
|
||||
'heterogeneousStatus.cloud.tokenChange': 'Change',
|
||||
'heterogeneousStatus.cloud.tokenSave': 'Save',
|
||||
'heterogeneousStatus.cloud.tokenCancel': 'Cancel',
|
||||
'heterogeneousStatus.cloud.githubLabel': 'GitHub Connection',
|
||||
'heterogeneousStatus.cloud.githubDesc':
|
||||
'Select a GitHub OAuth credential to allow the sandbox to clone your private repositories.',
|
||||
'heterogeneousStatus.cloud.githubPlaceholder': 'Select a GitHub credential...',
|
||||
'heterogeneousStatus.cloud.githubNoCreds': 'No GitHub credentials found.',
|
||||
'heterogeneousStatus.cloud.manageCredentials': 'Manage Credentials →',
|
||||
'heterogeneousStatus.cloud.repoLabel': 'Repositories',
|
||||
'heterogeneousStatus.cloud.repoDesc':
|
||||
'Add repositories to the list. Switch the active one from the bottom bar in the chat view.',
|
||||
'heterogeneousStatus.cloud.repoPlaceholder': 'owner/repo or https://github.com/owner/repo',
|
||||
'heterogeneousStatus.cloud.repoAdd': 'Add',
|
||||
|
||||
// Heterogeneous agent — Desktop tab
|
||||
'heterogeneousStatus.desktop.tabLabel': 'Desktop',
|
||||
|
||||
'checking': 'Checking...',
|
||||
|
||||
// Credentials Management
|
||||
|
||||
+13
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { Github } from '@lobehub/icons';
|
||||
import { Flexbox, Icon, Popover, Skeleton, Tooltip } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
@@ -14,6 +15,7 @@ import { memo, type ReactNode, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAgentId } from '@/features/ChatInput/hooks/useAgentId';
|
||||
import CloudRepoSwitcher from '@/features/ChatInput/RuntimeConfig/CloudRepoSwitcher';
|
||||
import GitStatus from '@/features/ChatInput/RuntimeConfig/GitStatus';
|
||||
import { useRepoType } from '@/features/ChatInput/RuntimeConfig/useRepoType';
|
||||
import WorkingDirectoryContent from '@/features/ChatInput/RuntimeConfig/WorkingDirectory';
|
||||
@@ -69,6 +71,7 @@ const WorkingDirectoryBar = memo(() => {
|
||||
const agentId = useAgentId();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// All hooks must be called unconditionally (Rules of Hooks)
|
||||
const isLoading = useAgentStore(agentByIdSelectors.isAgentConfigLoadingById(agentId));
|
||||
const agentWorkingDirectory = useAgentStore((s) =>
|
||||
agentId ? agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s) : undefined,
|
||||
@@ -85,6 +88,16 @@ const WorkingDirectoryBar = memo(() => {
|
||||
return <Icon icon={FolderIcon} size={14} />;
|
||||
}, [effectiveWorkingDirectory, repoType]);
|
||||
|
||||
// On web, show the cloud repo switcher instead of the local directory picker
|
||||
if (!isDesktop) {
|
||||
if (!agentId) return null;
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
|
||||
<CloudRepoSwitcher agentId={agentId} />
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
if (!agentId || isLoading) {
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} className={styles.bar} gap={4} justify={'space-between'}>
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
'use client';
|
||||
|
||||
import { type HeterogeneousProviderConfig, type UserCredSummary } from '@lobechat/types';
|
||||
import { Github } from '@lobehub/icons';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { Avatar, Button, Input, Select, Spin, Tag, Typography } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { CheckCircle2, KeyRound, X } from 'lucide-react';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { lambdaClient, lambdaQuery } from '@/libs/trpc/client';
|
||||
|
||||
// Fixed cred key for Claude Code OAuth token — never changes
|
||||
const CLAUDE_TOKEN_CRED_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
|
||||
|
||||
const useStyles = createStyles(({ css, token, cssVar }) => ({
|
||||
card: css`
|
||||
padding-block: 16px 12px;
|
||||
padding-inline: 16px;
|
||||
border: 1px solid ${token.colorBorderSecondary};
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
|
||||
background: ${token.colorBgContainer};
|
||||
`,
|
||||
credOption: css`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
`,
|
||||
manageLink: css`
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorPrimary};
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`,
|
||||
repoItem: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
min-height: 36px;
|
||||
padding-block: 6px;
|
||||
padding-inline: 8px;
|
||||
border-radius: ${token.borderRadiusSM}px;
|
||||
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: ${token.colorFillTertiary};
|
||||
|
||||
.repo-delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
repoItemActive: css`
|
||||
background: ${token.colorFillSecondary};
|
||||
`,
|
||||
repoDeleteBtn: css`
|
||||
cursor: pointer;
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
margin-inline-start: auto;
|
||||
padding: 2px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
|
||||
color: ${token.colorTextTertiary};
|
||||
|
||||
opacity: 0;
|
||||
background: transparent;
|
||||
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: ${token.colorError};
|
||||
}
|
||||
`,
|
||||
repoList: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
`,
|
||||
sectionDesc: css`
|
||||
font-size: 12px;
|
||||
color: ${token.colorTextSecondary};
|
||||
`,
|
||||
sectionDivider: css`
|
||||
margin-block: 12px;
|
||||
border-block-start: 1px solid ${token.colorBorderSecondary};
|
||||
`,
|
||||
sectionLabel: css`
|
||||
font-size: 12px;
|
||||
color: ${token.colorTextTertiary};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface CloudHeterogeneousConfigProps {
|
||||
onEnvChange: (env: Record<string, string>) => Promise<void> | void;
|
||||
provider: HeterogeneousProviderConfig;
|
||||
}
|
||||
|
||||
// ── Claude Code Token section ──────────────────────────────────────────────
|
||||
interface TokenSectionProps {
|
||||
existingCred: UserCredSummary | undefined;
|
||||
onEnvChange: (patch: Record<string, string>) => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
const TokenSection = memo<TokenSectionProps>(({ existingCred, onSaved, onEnvChange }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const { styles } = useStyles();
|
||||
const [editing, setEditing] = useState(!existingCred);
|
||||
const [tokenInput, setTokenInput] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
const token = tokenInput.trim();
|
||||
if (!token) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await lambdaClient.market.creds.createKV.mutate({
|
||||
key: CLAUDE_TOKEN_CRED_KEY,
|
||||
name: 'Claude Code OAuth Token',
|
||||
type: 'kv-env',
|
||||
values: { [CLAUDE_TOKEN_CRED_KEY]: token },
|
||||
});
|
||||
onEnvChange({ CLAUDE_CODE_CRED_KEY: CLAUDE_TOKEN_CRED_KEY });
|
||||
setTokenInput('');
|
||||
setEditing(false);
|
||||
onSaved();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox horizontal align="center" justify="space-between">
|
||||
<Flexbox horizontal align="center" gap={6}>
|
||||
<KeyRound size={12} />
|
||||
<span className={styles.sectionLabel}>{t('heterogeneousStatus.cloud.tokenLabel')}</span>
|
||||
</Flexbox>
|
||||
{existingCred && !editing && (
|
||||
<span className={styles.manageLink} onClick={() => setEditing(true)}>
|
||||
{t('heterogeneousStatus.cloud.tokenChange')}
|
||||
</span>
|
||||
)}
|
||||
</Flexbox>
|
||||
|
||||
{existingCred && !editing ? (
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<Tag
|
||||
color="success"
|
||||
icon={<CheckCircle2 size={11} />}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
{existingCred.maskedPreview ?? existingCred.name}
|
||||
</Tag>
|
||||
</Flexbox>
|
||||
) : (
|
||||
<Flexbox horizontal gap={8}>
|
||||
<Input.Password
|
||||
autoFocus={!!existingCred}
|
||||
placeholder={t('heterogeneousStatus.cloud.tokenPlaceholder')}
|
||||
style={{ flex: 1 }}
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
onPressEnter={handleSave}
|
||||
/>
|
||||
<Button loading={saving} type="primary" onClick={handleSave}>
|
||||
{t('heterogeneousStatus.cloud.tokenSave')}
|
||||
</Button>
|
||||
{existingCred && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setTokenInput('');
|
||||
}}
|
||||
>
|
||||
{t('heterogeneousStatus.cloud.tokenCancel')}
|
||||
</Button>
|
||||
)}
|
||||
</Flexbox>
|
||||
)}
|
||||
|
||||
<span className={styles.sectionDesc}>{t('heterogeneousStatus.cloud.tokenDesc')}</span>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Repo list section ──────────────────────────────────────────────────────
|
||||
// Profile page: manage the list of repos (add / delete only).
|
||||
// Active repo selection happens in the bottom-left CloudRepoSwitcher.
|
||||
interface RepoListSectionProps {
|
||||
onReposChange: (repos: string[]) => void;
|
||||
repos: string[];
|
||||
}
|
||||
|
||||
const RepoListSection = memo<RepoListSectionProps>(({ repos, onReposChange }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const { styles } = useStyles();
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const addRepo = () => {
|
||||
const v = input.trim();
|
||||
if (!v || repos.includes(v)) return;
|
||||
onReposChange([...repos, v]);
|
||||
setInput('');
|
||||
};
|
||||
|
||||
const removeRepo = (repo: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onReposChange(repos.filter((r) => r !== repo));
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<span className={styles.sectionLabel}>{t('heterogeneousStatus.cloud.repoLabel')}</span>
|
||||
|
||||
{repos.length > 0 && (
|
||||
<div className={styles.repoList}>
|
||||
{repos.map((repo) => (
|
||||
<div className={styles.repoItem} key={repo}>
|
||||
<Github size={14} style={{ flexShrink: 0 }} />
|
||||
<Typography.Text ellipsis style={{ flex: 1, fontSize: 13 }}>
|
||||
{repo}
|
||||
</Typography.Text>
|
||||
<button
|
||||
className={`${styles.repoDeleteBtn} repo-delete-btn`}
|
||||
onClick={(e) => removeRepo(repo, e)}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Flexbox horizontal gap={8}>
|
||||
<Input
|
||||
placeholder={t('heterogeneousStatus.cloud.repoPlaceholder')}
|
||||
style={{ flex: 1 }}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onPressEnter={addRepo}
|
||||
/>
|
||||
<Button onClick={addRepo}>{t('heterogeneousStatus.cloud.repoAdd')}</Button>
|
||||
</Flexbox>
|
||||
|
||||
<span className={styles.sectionDesc}>{t('heterogeneousStatus.cloud.repoDesc')}</span>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────────
|
||||
const CloudHeterogeneousConfig = memo<CloudHeterogeneousConfigProps>(
|
||||
({ provider, onEnvChange }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const { styles } = useStyles();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const currentEnv = provider.env ?? {};
|
||||
const storedGithubCredKey = currentEnv.GITHUB_CRED_KEY ?? '';
|
||||
const repos: string[] = (() => {
|
||||
try {
|
||||
return JSON.parse(currentEnv.GITHUB_REPOS ?? '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
const {
|
||||
data: credsData,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = lambdaQuery.market.creds.list.useQuery(undefined);
|
||||
const allCreds: UserCredSummary[] = credsData?.data ?? [];
|
||||
|
||||
const claudeTokenCred = allCreds.find((c) => c.key === CLAUDE_TOKEN_CRED_KEY);
|
||||
const githubCreds = allCreds.filter(
|
||||
(c) => c.type === 'oauth' && c.oauthProvider?.toLowerCase().includes('github'),
|
||||
);
|
||||
|
||||
const saveEnv = (patch: Record<string, string>) => {
|
||||
void onEnvChange({ ...currentEnv, ...patch });
|
||||
};
|
||||
|
||||
const handleReposChange = (nextRepos: string[]) => {
|
||||
saveEnv({ GITHUB_REPOS: JSON.stringify(nextRepos) });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Flexbox align="center" justify="center" style={{ paddingBlock: 32 }}>
|
||||
<Spin size="small" />
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<Flexbox gap={16}>
|
||||
{/* ── Claude Code OAuth Token ── */}
|
||||
<TokenSection
|
||||
existingCred={claudeTokenCred}
|
||||
onEnvChange={saveEnv}
|
||||
onSaved={() => refetch()}
|
||||
/>
|
||||
|
||||
<div className={styles.sectionDivider} />
|
||||
|
||||
{/* ── GitHub OAuth Credential ── */}
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox horizontal align="center" justify="space-between">
|
||||
<Flexbox horizontal align="center" gap={6}>
|
||||
<Github size={12} />
|
||||
<span className={styles.sectionLabel}>
|
||||
{t('heterogeneousStatus.cloud.githubLabel')}
|
||||
</span>
|
||||
</Flexbox>
|
||||
<span className={styles.manageLink} onClick={() => navigate('/settings/creds')}>
|
||||
{t('heterogeneousStatus.cloud.manageCredentials')}
|
||||
</span>
|
||||
</Flexbox>
|
||||
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t('heterogeneousStatus.cloud.githubPlaceholder')}
|
||||
style={{ width: '100%' }}
|
||||
value={storedGithubCredKey || undefined}
|
||||
notFoundContent={
|
||||
<Flexbox style={{ padding: '8px 0', fontSize: 12 }}>
|
||||
{t('heterogeneousStatus.cloud.githubNoCreds')}
|
||||
</Flexbox>
|
||||
}
|
||||
onChange={(key: string) => saveEnv({ GITHUB_CRED_KEY: key })}
|
||||
onClear={() => saveEnv({ GITHUB_CRED_KEY: '' })}
|
||||
>
|
||||
{githubCreds.map((cred) => (
|
||||
<Select.Option key={cred.key} value={cred.key}>
|
||||
<span className={styles.credOption}>
|
||||
{cred.oauthAvatar ? (
|
||||
<Avatar size={16} src={cred.oauthAvatar} />
|
||||
) : (
|
||||
<Github size={14} />
|
||||
)}
|
||||
<span>{cred.name}</span>
|
||||
{cred.oauthUsername && (
|
||||
<Typography.Text style={{ fontSize: 12 }} type="secondary">
|
||||
@{cred.oauthUsername}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</span>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<span className={styles.sectionDesc}>{t('heterogeneousStatus.cloud.githubDesc')}</span>
|
||||
</Flexbox>
|
||||
|
||||
<div className={styles.sectionDivider} />
|
||||
|
||||
{/* ── Repository list ── */}
|
||||
<RepoListSection repos={repos} onReposChange={handleReposChange} />
|
||||
</Flexbox>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default CloudHeterogeneousConfig;
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { Divider } from 'antd';
|
||||
import { Divider, Tabs } from 'antd';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import React, { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ModelSelect from '@/features/ModelSelect';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
@@ -15,23 +17,31 @@ import AgentSettings from '../AgentSettings';
|
||||
import EditorCanvas from '../EditorCanvas';
|
||||
import AgentHeader from './AgentHeader';
|
||||
import AgentTool from './AgentTool';
|
||||
import CloudHeterogeneousConfig from './CloudHeterogeneousConfig';
|
||||
import HeterogeneousAgentStatusCard from './HeterogeneousAgentStatusCard';
|
||||
|
||||
const ProfileEditor = memo(() => {
|
||||
const { t } = useTranslation('setting');
|
||||
const config = useAgentStore(agentSelectors.currentAgentConfig, isEqual);
|
||||
const updateConfig = useAgentStore((s) => s.updateAgentConfig);
|
||||
const isHeterogeneous = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
|
||||
const enableBusinessFeatures = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures);
|
||||
const heterogeneousProvider = config.agencyConfig?.heterogeneousProvider;
|
||||
|
||||
const updateHeterogeneousCommand = async (command: string) => {
|
||||
if (!heterogeneousProvider) return;
|
||||
|
||||
await updateConfig({
|
||||
agencyConfig: {
|
||||
heterogeneousProvider: {
|
||||
...heterogeneousProvider,
|
||||
command,
|
||||
},
|
||||
heterogeneousProvider: { ...heterogeneousProvider, command },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateHeterogeneousEnv = async (env: Record<string, string>) => {
|
||||
if (!heterogeneousProvider) return;
|
||||
await updateConfig({
|
||||
agencyConfig: {
|
||||
heterogeneousProvider: { ...heterogeneousProvider, env },
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -47,10 +57,33 @@ const ProfileEditor = memo(() => {
|
||||
{/* Header: Avatar + Name + Description */}
|
||||
<AgentHeader />
|
||||
{isHeterogeneous && heterogeneousProvider ? (
|
||||
// Heterogeneous integration mode: show provider CLI status instead of model/skills pickers
|
||||
<HeterogeneousAgentStatusCard
|
||||
provider={heterogeneousProvider}
|
||||
onCommandChange={updateHeterogeneousCommand}
|
||||
// Heterogeneous integration mode: tabs for cloud (web) and desktop environments
|
||||
<Tabs
|
||||
defaultActiveKey={isDesktop ? 'desktop' : 'cloud'}
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: 'cloud',
|
||||
label: t('heterogeneousStatus.cloud.tabLabel'),
|
||||
children: (
|
||||
<CloudHeterogeneousConfig
|
||||
provider={heterogeneousProvider}
|
||||
onEnvChange={updateHeterogeneousEnv}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'desktop',
|
||||
label: t('heterogeneousStatus.desktop.tabLabel'),
|
||||
disabled: !isDesktop,
|
||||
children: (
|
||||
<HeterogeneousAgentStatusCard
|
||||
provider={heterogeneousProvider}
|
||||
onCommandChange={updateHeterogeneousCommand}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -142,6 +142,12 @@ const ExecAgentSchema = z
|
||||
defaultTaskAssigneeAgentId: z.string().optional(),
|
||||
documentId: z.string().optional().nullable(),
|
||||
groupId: z.string().optional().nullable(),
|
||||
initialTopicMetadata: z
|
||||
.object({
|
||||
repos: z.array(z.string()).optional(),
|
||||
workingDirectory: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
scope: z.string().optional().nullable(),
|
||||
sessionId: z.string().optional(),
|
||||
taskId: z.string().optional().nullable(),
|
||||
|
||||
@@ -626,6 +626,7 @@ export const topicRouter = router({
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
repos: z.array(z.string()).optional(),
|
||||
workingDirectory: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -588,14 +588,20 @@ export class AiAgentService {
|
||||
throw new Error('Resume mode requires the parent message to belong to a topic');
|
||||
}
|
||||
|
||||
// Prepare metadata with cronJobId, taskId, botContext, and bound device if provided
|
||||
// Prepare metadata with cronJobId, taskId, botContext, bound device, and any
|
||||
// client-supplied initial metadata (e.g. repos selected before first message).
|
||||
const initialTopicMeta = appContext?.initialTopicMetadata;
|
||||
const metadata =
|
||||
cronJobId || operationTaskId || botContext || requestedDeviceId
|
||||
cronJobId || operationTaskId || botContext || requestedDeviceId || initialTopicMeta
|
||||
? {
|
||||
bot: botContext,
|
||||
boundDeviceId: requestedDeviceId,
|
||||
cronJobId: cronJobId || undefined,
|
||||
taskId: operationTaskId,
|
||||
...(initialTopicMeta?.repos && { repos: initialTopicMeta.repos }),
|
||||
...(initialTopicMeta?.workingDirectory && {
|
||||
workingDirectory: initialTopicMeta.workingDirectory,
|
||||
}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -674,12 +680,47 @@ export class AiAgentService {
|
||||
throw new Error('Failed to sign operation JWT for hetero agent', { cause: err });
|
||||
}
|
||||
|
||||
// Read repos from topic metadata for sandbox setup (web/cloud only).
|
||||
const topic = await this.topicModel.findById(topicId);
|
||||
const topicRepos: string[] = topic?.metadata?.repos ?? [];
|
||||
|
||||
// Resolve GitHub OAuth token so the sandbox can clone private repos.
|
||||
let githubToken: string | undefined;
|
||||
if (topicRepos.length > 0) {
|
||||
const githubCredKey = agentConfig.agencyConfig?.heterogeneousProvider?.env?.GITHUB_CRED_KEY;
|
||||
if (githubCredKey) {
|
||||
try {
|
||||
const list = await this.marketService.market.creds.list();
|
||||
const cred = list.data?.find((c: { key: string }) => c.key === githubCredKey);
|
||||
if (cred) {
|
||||
const full = await this.marketService.market.creds.get(cred.id, { decrypt: true });
|
||||
const vals = (full as any).plaintext ?? (full as any).values ?? {};
|
||||
githubToken = vals.access_token ?? vals.token;
|
||||
}
|
||||
} catch (err) {
|
||||
log('execAgent: failed to resolve GitHub token for repo clone: %O', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build cloud-specific system context (repo list + workspace info + optional agent-level static context).
|
||||
const { buildCloudHeteroContext } =
|
||||
await import('@/server/services/heterogeneousAgent/cloudHeteroContext');
|
||||
const systemContext = buildCloudHeteroContext({
|
||||
agentSystemContext: agentConfig.agencyConfig?.heterogeneousProvider?.systemContext,
|
||||
githubToken,
|
||||
repos: topicRepos,
|
||||
});
|
||||
|
||||
const heteroParams = {
|
||||
agentType: heteroType,
|
||||
githubToken,
|
||||
jwt: operationJwt,
|
||||
operationId,
|
||||
prompt,
|
||||
repos: topicRepos,
|
||||
resumeSessionId,
|
||||
systemContext,
|
||||
topicId,
|
||||
userId: this.userId,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Builds the system context injected before every user prompt for cloud Claude Code runs.
|
||||
*
|
||||
* This context is cloud-sandbox-specific: it describes the workspace layout,
|
||||
* lists the GitHub repos that were pre-cloned, and tells CC how to handle
|
||||
* repos that may not have been cloned successfully.
|
||||
*
|
||||
* It is NOT the agent's systemRole (which lives in agentConfig.systemRole and
|
||||
* is a user-facing persona definition). This is pure infra context for CC.
|
||||
*
|
||||
* Returned string is passed as the first text block in the --input-json array
|
||||
* via sandboxRunner → spawnHeteroSandbox. If nothing meaningful to inject,
|
||||
* returns undefined so no extra block is added.
|
||||
*/
|
||||
export function buildCloudHeteroContext(params: {
|
||||
repos: string[];
|
||||
/** Static systemContext from HeterogeneousProviderConfig.systemContext (agent-level). */
|
||||
agentSystemContext?: string;
|
||||
/** GitHub OAuth token injected as GITHUB_TOKEN env var in the sandbox. */
|
||||
githubToken?: string;
|
||||
}): string {
|
||||
const { repos, agentSystemContext, githubToken } = params;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// --- Agent-level static context (highest priority, goes first) ---
|
||||
if (agentSystemContext?.trim()) {
|
||||
parts.push(agentSystemContext.trim());
|
||||
}
|
||||
|
||||
// --- Cloud workspace context ---
|
||||
const workspaceLines: string[] = [
|
||||
'## Cloud Workspace',
|
||||
'You are running inside a LobeHub cloud sandbox. Your working directory is `/workspace`.',
|
||||
];
|
||||
|
||||
if (githubToken) {
|
||||
workspaceLines.push(
|
||||
'',
|
||||
'## GitHub Authentication',
|
||||
'A GitHub OAuth token is available as the `GITHUB_TOKEN` environment variable.',
|
||||
'Use it for:',
|
||||
'- Authenticated git operations (`git clone`, `git push`, `git pull`, etc.)',
|
||||
'- GitHub CLI: `gh` commands work out of the box',
|
||||
'- GitHub API calls: pass it as `Authorization: Bearer $GITHUB_TOKEN`',
|
||||
'- Accessing private repositories',
|
||||
);
|
||||
}
|
||||
|
||||
if (repos.length > 0) {
|
||||
workspaceLines.push(
|
||||
'',
|
||||
'## GitHub Repositories',
|
||||
'The following repositories were pre-cloned into `/workspace` before this conversation started:',
|
||||
...repos.map((repo) => {
|
||||
const dir = repoToLocalDir(repo);
|
||||
const url = toGithubUrl(repo);
|
||||
return `- \`/workspace/${dir}\` (${url})`;
|
||||
}),
|
||||
'',
|
||||
'You can start working in any of these directories immediately.',
|
||||
githubToken
|
||||
? 'If a directory is missing (clone may have failed), you can recover it yourself using the available GITHUB_TOKEN.'
|
||||
: 'If a directory is missing (clone may have failed), you can run `git clone <url> /workspace/<dir>` yourself to recover it.',
|
||||
);
|
||||
} else {
|
||||
workspaceLines.push(
|
||||
'',
|
||||
'No GitHub repositories have been pre-cloned for this conversation.',
|
||||
githubToken
|
||||
? 'If you need a repository, you can clone it yourself using the available GITHUB_TOKEN.'
|
||||
: 'If you need a repository, ask the user to add it in the repo selector, or clone it yourself with `git clone <url> /workspace/<dir>`.',
|
||||
);
|
||||
}
|
||||
|
||||
parts.push(workspaceLines.join('\n'));
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers (mirrors sandboxRunner logic — kept local to avoid coupling)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function repoToLocalDir(repo: string): string {
|
||||
return (repo.split('/').findLast(Boolean) ?? repo).replace(/\.git$/, '');
|
||||
}
|
||||
|
||||
function toGithubUrl(repo: string): string {
|
||||
if (repo.startsWith('http')) return repo.replace(/\.git$/, '');
|
||||
return `https://github.com/${repo}`;
|
||||
}
|
||||
@@ -8,16 +8,64 @@ const log = debug('lobe-server:hetero-sandbox-runner');
|
||||
export interface SandboxRunParams {
|
||||
agentType: 'claude-code' | 'codex';
|
||||
cwd?: string;
|
||||
/** GitHub OAuth token for cloning private repos. */
|
||||
githubToken?: string;
|
||||
/** Operation-scoped JWT injected as LOBEHUB_JWT env in the sandbox. */
|
||||
jwt: string;
|
||||
marketService: MarketService;
|
||||
operationId: string;
|
||||
prompt: string;
|
||||
/** GitHub repos to clone before running the agent (e.g. ['owner/repo', ...]). */
|
||||
repos?: string[];
|
||||
resumeSessionId?: string;
|
||||
/**
|
||||
* Optional context injected as a text block BEFORE the user's prompt.
|
||||
* Useful for priming CC with workspace state (cloned repos, env info, etc.).
|
||||
* Passed via --input-json as a JSON content-block array — lh already supports this.
|
||||
*/
|
||||
systemContext?: string;
|
||||
topicId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the local directory name from a repo identifier.
|
||||
* Accepts "owner/repo", "https://github.com/owner/repo", or "https://github.com/owner/repo.git".
|
||||
* Only allows safe characters to prevent shell injection.
|
||||
*/
|
||||
function repoToLocalDir(repo: string): string {
|
||||
const raw = (repo.split('/').findLast(Boolean) ?? repo).replace(/\.git$/, '');
|
||||
return raw.replaceAll(/[^\w.-]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an idempotent setup script that clones each repo if not already present.
|
||||
* Uses `[ -d <dir> ] || git clone ...` so re-runs on the same sandbox are no-ops.
|
||||
* Returns null when repos is empty.
|
||||
*/
|
||||
function buildRepoSetupScript(repos: string[], githubToken?: string): string | null {
|
||||
if (!repos || repos.length === 0) return null;
|
||||
|
||||
const lines = repos.map((repo) => {
|
||||
const dir = repoToLocalDir(repo);
|
||||
// Normalise to "owner/repo" for the clone URL
|
||||
const repoPath = repo.startsWith('http') ? (repo.split('github.com/')[1] ?? repo) : repo;
|
||||
// Use git's insteadOf rewrite (passed via -c, not stored in .git/config) so the token
|
||||
// never ends up in the cloned repo's remote URL.
|
||||
// GIT_TERMINAL_PROMPT=0 prevents git from blocking on a credential prompt for
|
||||
// private repos — it fails immediately instead of hanging the whole sandbox.
|
||||
// timeout 120 provides a hard deadline so a slow clone never stalls the first message.
|
||||
const cloneCmd = githubToken
|
||||
? `GIT_TERMINAL_PROMPT=0 timeout 120 git -c "url.https://oauth2:${githubToken}@github.com/.insteadOf=https://github.com/" clone -q https://github.com/${repoPath} '${dir}'`
|
||||
: `GIT_TERMINAL_PROMPT=0 timeout 120 git clone -q 'https://github.com/${repoPath}' '${dir}'`;
|
||||
|
||||
// `|| true` makes clone failures non-fatal — CC still runs even if a repo can't be cloned.
|
||||
return `{ [ -d '${dir}' ] || ${cloneCmd}; } || true`;
|
||||
});
|
||||
|
||||
return lines.join(' && \\\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches `lh hetero exec` inside the cloud sandbox via `runCommand`.
|
||||
*
|
||||
@@ -34,16 +82,22 @@ export interface SandboxRunParams {
|
||||
export async function spawnHeteroSandbox(params: SandboxRunParams): Promise<void> {
|
||||
const {
|
||||
agentType,
|
||||
cwd,
|
||||
githubToken,
|
||||
jwt,
|
||||
marketService,
|
||||
operationId,
|
||||
prompt,
|
||||
repos,
|
||||
resumeSessionId,
|
||||
topicId,
|
||||
userId,
|
||||
} = params;
|
||||
|
||||
// cwd is only set when explicitly passed (e.g. desktop local path).
|
||||
// For cloud sandbox, CC always runs in the sandbox root (/workspace); repos are cloned
|
||||
// as subdirectories there and CC navigates into them via its own tools.
|
||||
const { cwd } = params;
|
||||
|
||||
// Build the `lh hetero exec` command string.
|
||||
// Prompt is passed via --input-json stdin ('-') to avoid shell quoting issues
|
||||
// with arbitrary user text in --prompt.
|
||||
@@ -71,14 +125,32 @@ export async function spawnHeteroSandbox(params: SandboxRunParams): Promise<void
|
||||
|
||||
// Encode the prompt as base64 to avoid all shell quoting issues.
|
||||
// echo + shell quoting mangled inner JSON quotes; base64 is quote-safe.
|
||||
const stdinPayload = JSON.stringify(prompt);
|
||||
// When systemContext is provided, send a content-block array so CC sees the
|
||||
// context block first, then the user's actual message. lh already handles
|
||||
// JSON arrays via coerceJsonPrompt — no lh changes required.
|
||||
const { systemContext } = params;
|
||||
const stdinPayload = systemContext
|
||||
? JSON.stringify([
|
||||
{ text: systemContext, type: 'text' },
|
||||
{ text: prompt, type: 'text' },
|
||||
])
|
||||
: JSON.stringify(prompt);
|
||||
const base64Payload = Buffer.from(stdinPayload).toString('base64');
|
||||
|
||||
// LOBEHUB_HETERO_SERVER_URL overrides the server URL for local dev/testing
|
||||
// (e.g. a cloudflare tunnel). APP_URL is NOT used here because it's tied to
|
||||
// auth callbacks and must stay as localhost in dev.
|
||||
const serverUrl = process.env.LOBEHUB_HETERO_SERVER_URL ?? appEnv.APP_URL;
|
||||
const shellCommand = `echo ${base64Payload} | base64 -d | LOBEHUB_JWT=${JSON.stringify(jwt)} LOBEHUB_SERVER=${JSON.stringify(serverUrl)} ${args.join(' ')}`;
|
||||
const envVars = [
|
||||
`LOBEHUB_JWT=${JSON.stringify(jwt)}`,
|
||||
`LOBEHUB_SERVER=${JSON.stringify(serverUrl)}`,
|
||||
// Inject GitHub token so CC can authenticate git operations and GitHub API
|
||||
// calls inside the sandbox (e.g. gh CLI, git push, API requests).
|
||||
...(githubToken ? [`GITHUB_TOKEN=${JSON.stringify(githubToken)}`] : []),
|
||||
].join(' ');
|
||||
const mainCommand = `echo ${base64Payload} | base64 -d | ${envVars} ${args.join(' ')}`;
|
||||
const setupScript = buildRepoSetupScript(repos ?? [], githubToken);
|
||||
const shellCommand = setupScript ? `${setupScript} && \\\n${mainCommand}` : mainCommand;
|
||||
|
||||
log(
|
||||
'spawnHeteroSandbox: userId=%s op=%s type=%s topic=%s',
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Module-level singleton for pending repo selections.
|
||||
*
|
||||
* When a user selects GitHub repos before sending the first message (no topic
|
||||
* exists yet), the selections are buffered here keyed by agentId. As soon as
|
||||
* the server creates a topic for that agent, gateway.ts consumes these repos
|
||||
* and writes them into the topic metadata immediately — avoiding the race
|
||||
* condition where the store action would drop the update because the topic
|
||||
* object hadn't appeared in topicDataMap yet.
|
||||
*
|
||||
* Desktop builds: CloudRepoSwitcher is never rendered, so these functions are
|
||||
* never called and the map stays empty.
|
||||
*/
|
||||
|
||||
const map = new Map<string, string[]>();
|
||||
|
||||
/** Record pending repos for an agent (overwrites previous value). */
|
||||
export const setPendingTopicRepos = (agentId: string, repos: string[]): void => {
|
||||
if (repos.length === 0) map.delete(agentId);
|
||||
else map.set(agentId, [...repos]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Consume and return pending repos for an agent.
|
||||
* Clears the entry so a second call returns [].
|
||||
*/
|
||||
export const consumePendingTopicRepos = (agentId: string): string[] => {
|
||||
const repos = map.get(agentId);
|
||||
map.delete(agentId);
|
||||
return repos ?? [];
|
||||
};
|
||||
|
||||
/** Read pending repos without consuming them (for display). */
|
||||
export const getPendingTopicRepos = (agentId: string): string[] => map.get(agentId) ?? [];
|
||||
@@ -16,13 +16,13 @@ describe('selectRuntimeType', () => {
|
||||
expect(selectRuntimeType({ isGatewayMode: true }, opts)).toBe('gateway');
|
||||
});
|
||||
|
||||
it('ignores heterogeneousProvider on web — falls through to gateway/client', () => {
|
||||
it('routes heterogeneousProvider to gateway on web — cloud sandbox is the only execution env', () => {
|
||||
expect(
|
||||
selectRuntimeType({ heterogeneousProvider: heteroProvider, isGatewayMode: true }, opts),
|
||||
).toBe('gateway');
|
||||
expect(
|
||||
selectRuntimeType({ heterogeneousProvider: heteroProvider, isGatewayMode: false }, opts),
|
||||
).toBe('client');
|
||||
).toBe('gateway');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ export const selectRuntimeType = (
|
||||
): AgentRuntimeType => {
|
||||
if (ctx.parentRuntime) return ctx.parentRuntime;
|
||||
if (isDesktop && ctx.heterogeneousProvider) return 'hetero';
|
||||
// On web, heterogeneous agents always run via Gateway sandbox regardless of the
|
||||
// isGatewayMode user preference — the sandbox is the only execution environment.
|
||||
if (!isDesktop && ctx.heterogeneousProvider) return 'gateway';
|
||||
if (ctx.isGatewayMode) return 'gateway';
|
||||
return 'client';
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { isDesktop } from '@/const/version';
|
||||
import { aiAgentService, type ResumeApprovalParam } from '@/services/aiAgent';
|
||||
import { messageService } from '@/services/message';
|
||||
import { topicService } from '@/services/topic';
|
||||
import { consumePendingTopicRepos, getPendingTopicRepos } from '@/store/chat/pendingTopicRepos';
|
||||
import type { ChatStore } from '@/store/chat/store';
|
||||
import type { StoreSetter } from '@/store/types';
|
||||
import { useUserStore } from '@/store/user';
|
||||
@@ -291,6 +292,17 @@ export class GatewayActionImpl {
|
||||
const isCreateNewTopic = !context.topicId;
|
||||
const taskId = context.viewedTask?.type === 'detail' ? context.viewedTask.taskId : undefined;
|
||||
|
||||
// If this is a new topic, read any repos the user pre-selected before
|
||||
// sending the first message. We read without consuming yet — if execAgentTask
|
||||
// fails or is aborted, the selection is preserved so a retry can still pick
|
||||
// it up. We clear only after the server confirms the topic was created.
|
||||
const pendingRepos =
|
||||
isCreateNewTopic && context.agentId ? getPendingTopicRepos(context.agentId) : [];
|
||||
const initialTopicMetadata =
|
||||
pendingRepos.length > 0
|
||||
? { repos: pendingRepos, workingDirectory: pendingRepos[0] }
|
||||
: undefined;
|
||||
|
||||
// Honour user-initiated cancel during phase-1 init: while we await the
|
||||
// execAgentTask round-trip the caller's loading state (e.g. `sendMessage`)
|
||||
// is still running, so the ChatInput stop button is active. Forward the
|
||||
@@ -309,6 +321,7 @@ export class GatewayActionImpl {
|
||||
defaultTaskAssigneeAgentId: context.defaultTaskAssigneeAgentId,
|
||||
documentId: context.documentId,
|
||||
groupId: context.groupId,
|
||||
...(initialTopicMetadata && { initialTopicMetadata }),
|
||||
scope: context.scope,
|
||||
taskId,
|
||||
threadId: context.threadId,
|
||||
@@ -337,6 +350,8 @@ export class GatewayActionImpl {
|
||||
// If server created a new topic, fetch messages first then switch topic
|
||||
// (same pattern as client mode: replaceMessages before switchTopic to avoid skeleton flash)
|
||||
if (isCreateNewTopic && result.topicId) {
|
||||
// Topic created successfully — now safe to clear the pending repo selection.
|
||||
if (context.agentId) consumePendingTopicRepos(context.agentId);
|
||||
try {
|
||||
const newContext = { ...context, topicId: result.topicId };
|
||||
const messages = await messageService.getMessages(newContext);
|
||||
@@ -350,6 +365,14 @@ export class GatewayActionImpl {
|
||||
skipRefreshMessage: true,
|
||||
});
|
||||
|
||||
// Refresh the topic list so the new topic appears in topicDataMap (sidebar).
|
||||
// Unlike the direct-API sendMessage path (which receives topics[] in the
|
||||
// response and calls internal_updateTopics), the gateway path only gets a
|
||||
// topicId — we must explicitly refetch so the sidebar shows the new topic.
|
||||
this.#get()
|
||||
.refreshTopic()
|
||||
.catch((err) => console.error('[Gateway] refreshTopic after topic creation failed:', err));
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
aiAgentService
|
||||
.interruptTask({ operationId: result.operationId })
|
||||
|
||||
@@ -76,15 +76,22 @@ const currentActiveTopicSummary = (s: ChatStoreState): ChatTopicSummary | undefi
|
||||
};
|
||||
};
|
||||
|
||||
const currentTopicMetadata = (s: ChatStoreState) => currentActiveTopic(s)?.metadata;
|
||||
|
||||
/**
|
||||
* Get current active topic's working directory
|
||||
* Returns undefined if no topic is active or no working directory is set
|
||||
* Get current active topic's working directory.
|
||||
* On desktop: local filesystem path.
|
||||
* On web (cloud): primary GitHub repo URL (repos[0]), or workingDirectory if set directly.
|
||||
*/
|
||||
const currentTopicWorkingDirectory = (s: ChatStoreState): string | undefined => {
|
||||
if (!isDesktop) return;
|
||||
|
||||
const activeTopic = currentActiveTopic(s);
|
||||
return activeTopic?.metadata?.workingDirectory;
|
||||
if (!activeTopic) return;
|
||||
|
||||
if (isDesktop) return activeTopic.metadata?.workingDirectory;
|
||||
|
||||
// Web: return primary repo from repos list, or workingDirectory if set directly
|
||||
const meta = activeTopic.metadata;
|
||||
return meta?.repos?.[0] ?? meta?.workingDirectory;
|
||||
};
|
||||
|
||||
const isCreatingTopic = (s: ChatStoreState) => s.creatingTopic;
|
||||
@@ -175,6 +182,7 @@ export const topicSelectors = {
|
||||
currentTopicCount,
|
||||
currentTopicData,
|
||||
currentTopicLength,
|
||||
currentTopicMetadata,
|
||||
currentTopicWorkingDirectory,
|
||||
currentTopics,
|
||||
currentTopicsWithoutCron,
|
||||
|
||||
Reference in New Issue
Block a user