mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 12:10:16 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f049ab1610 | |||
| 542aacd07f | |||
| 4be4bc69c2 | |||
| a62def2fe9 | |||
| 54f972b7dd | |||
| be676bca4f | |||
| cdfa64956f |
@@ -0,0 +1,96 @@
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Detect step boundaries in Claude Code stream-json output.
|
||||
*
|
||||
* A "step" boundary occurs when a new assistant `message.id` appears,
|
||||
* indicating the start of a new CC turn. We flush the accumulated lines
|
||||
* for the previous step before starting the new one.
|
||||
*
|
||||
* On stdin EOF (CC process exits), we flush the remaining buffer.
|
||||
*/
|
||||
export function registerIngestCommand(program: Command) {
|
||||
program
|
||||
.command('ingest')
|
||||
.description(
|
||||
'Pipe Claude Code stream-json stdout to LobeHub, persisting structured messages per step',
|
||||
)
|
||||
.requiredOption('--topic-id <id>', 'Target topic ID')
|
||||
.option('--agent-id <id>', 'Agent ID')
|
||||
.option('--json', 'Output JSON results')
|
||||
.action(async (options: { agentId?: string; json?: boolean; topicId: string }) => {
|
||||
log.debug('ingest: topicId=%s, agentId=%s', options.topicId, options.agentId);
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const rl = createInterface({ input: process.stdin });
|
||||
|
||||
let buffer: any[] = [];
|
||||
let currentMessageId: string | undefined;
|
||||
let stepCount = 0;
|
||||
|
||||
const flush = async (lines: any[]) => {
|
||||
if (lines.length === 0) return;
|
||||
stepCount++;
|
||||
|
||||
try {
|
||||
const result = await (client as any).cloudClaudeCode.ingest.mutate({
|
||||
agentId: options.agentId,
|
||||
lines,
|
||||
topicId: options.topicId,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ step: stepCount, ...result }));
|
||||
} else {
|
||||
const toolInfo =
|
||||
result.toolMessageIds?.length > 0 ? ` + ${result.toolMessageIds.length} tool(s)` : '';
|
||||
console.error(
|
||||
`${pc.green('↑')} Step ${stepCount}: ${pc.bold(result.assistantMessageId || 'no-msg')}${toolInfo}`,
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`${pc.red('✗')} Step ${stepCount} failed: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
for await (const raw of rl) {
|
||||
let line: any;
|
||||
try {
|
||||
line = JSON.parse(raw);
|
||||
} catch {
|
||||
// Skip non-JSON lines (stderr leaks, etc.)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect step boundary: assistant message.id change
|
||||
if (line.type === 'assistant' && line.message?.id) {
|
||||
if (currentMessageId && line.message.id !== currentMessageId) {
|
||||
// New message.id → previous step is complete → flush
|
||||
const prevStepLines = buffer;
|
||||
buffer = [line];
|
||||
await flush(prevStepLines);
|
||||
} else {
|
||||
buffer.push(line);
|
||||
}
|
||||
currentMessageId = line.message.id;
|
||||
} else {
|
||||
buffer.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// stdin EOF → CC finished → flush remaining
|
||||
await flush(buffer);
|
||||
|
||||
if (!options.json) {
|
||||
console.error(
|
||||
`${pc.green('✓')} Done: ${stepCount} step(s) ingested to topic ${pc.bold(options.topicId)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { registerDocCommand } from './commands/doc';
|
||||
import { registerEvalCommand } from './commands/eval';
|
||||
import { registerFileCommand } from './commands/file';
|
||||
import { registerGenerateCommand } from './commands/generate';
|
||||
import { registerIngestCommand } from './commands/ingest';
|
||||
import { registerKbCommand } from './commands/kb';
|
||||
import { registerLoginCommand } from './commands/login';
|
||||
import { registerLogoutCommand } from './commands/logout';
|
||||
@@ -61,6 +62,7 @@ export function createProgram() {
|
||||
registerBotCommand(program);
|
||||
registerCronCommand(program);
|
||||
registerGenerateCommand(program);
|
||||
registerIngestCommand(program);
|
||||
registerFileCommand(program);
|
||||
registerSkillCommand(program);
|
||||
registerSessionGroupCommand(program);
|
||||
|
||||
@@ -68,6 +68,25 @@
|
||||
"cliRateLimitGuide.resetAt": "Resets at",
|
||||
"cliRateLimitGuide.resetInApprox": "Resets in about {{duration}}",
|
||||
"cliRateLimitGuide.title": "{{name}} usage limit reached",
|
||||
"cloudClaudeCodeSetup.actions.cancel": "Cancel",
|
||||
"cloudClaudeCodeSetup.actions.confirm": "Continue",
|
||||
"cloudClaudeCodeSetup.desc": "Before creating the agent, complete the Claude Code token and GitHub authorization setup.",
|
||||
"cloudClaudeCodeSetup.errors.githubRequired": "Connect GitHub before continuing.",
|
||||
"cloudClaudeCodeSetup.errors.refresh": "Failed to refresh the current authorization status.",
|
||||
"cloudClaudeCodeSetup.errors.submit": "Failed to save the Cloud Claude Code prerequisites.",
|
||||
"cloudClaudeCodeSetup.errors.tokenRequired": "Paste your CLAUDE_CODE_OAUTH_TOKEN first.",
|
||||
"cloudClaudeCodeSetup.github.authorized": "GitHub authorization is already available. We will create the injectable credential automatically when you continue.",
|
||||
"cloudClaudeCodeSetup.github.connected": "Detected an available GitHub credential.",
|
||||
"cloudClaudeCodeSetup.github.desc": "Claude Code will use your GitHub identity to access repositories and write code for you.",
|
||||
"cloudClaudeCodeSetup.github.footer": "After GitHub authorization succeeds, continue creating the agent.",
|
||||
"cloudClaudeCodeSetup.github.title": "GitHub Authorization",
|
||||
"cloudClaudeCodeSetup.title": "Enable Cloud Claude Code",
|
||||
"cloudClaudeCodeSetup.token.commandPrefix": "Run this inside your Claude Code session",
|
||||
"cloudClaudeCodeSetup.token.connected": "Detected an existing CLAUDE_CODE_OAUTH_TOKEN and will reuse it.",
|
||||
"cloudClaudeCodeSetup.token.desc": "This token lets Cloud Claude Code run on your behalf.",
|
||||
"cloudClaudeCodeSetup.token.hint": "If you do not have the token yet, get the long-lived credential from your own Claude Code first.",
|
||||
"cloudClaudeCodeSetup.token.placeholder": "Paste CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"cloudClaudeCodeSetup.token.title": "Claude Code Token",
|
||||
"codexInstallGuide.actions.openDocs": "Open Install Guide",
|
||||
"codexInstallGuide.actions.openSystemTools": "Open System Tools",
|
||||
"codexInstallGuide.afterInstall": "After installing, run Codex once to sign in, then retry your message or click Re-detect in System Tools.",
|
||||
@@ -294,6 +313,7 @@
|
||||
"minimap.senderUser": "You",
|
||||
"newAgent": "Create Agent",
|
||||
"newClaudeCodeAgent": "Add Claude Code",
|
||||
"newCloudClaudeCode": "Add Cloud Claude Code",
|
||||
"newCodexAgent": "Add Codex",
|
||||
"newGroupChat": "Create Group",
|
||||
"newPage": "Create Page",
|
||||
|
||||
@@ -68,6 +68,25 @@
|
||||
"cliRateLimitGuide.resetAt": "重置时间",
|
||||
"cliRateLimitGuide.resetInApprox": "约 {{duration}} 后重置",
|
||||
"cliRateLimitGuide.title": "{{name}} 已达到使用上限",
|
||||
"cloudClaudeCodeSetup.actions.cancel": "取消",
|
||||
"cloudClaudeCodeSetup.actions.confirm": "继续创建",
|
||||
"cloudClaudeCodeSetup.desc": "创建前需要先完成 Claude Code 凭证和 GitHub 授权。",
|
||||
"cloudClaudeCodeSetup.errors.githubRequired": "请先完成 GitHub 授权。",
|
||||
"cloudClaudeCodeSetup.errors.refresh": "刷新当前授权状态失败。",
|
||||
"cloudClaudeCodeSetup.errors.submit": "保存 Cloud Claude Code 前置凭证失败。",
|
||||
"cloudClaudeCodeSetup.errors.tokenRequired": "请先填写 CLAUDE_CODE_OAUTH_TOKEN。",
|
||||
"cloudClaudeCodeSetup.github.authorized": "已检测到 GitHub 授权,继续创建时会自动生成可注入凭证。",
|
||||
"cloudClaudeCodeSetup.github.connected": "已检测到可用的 GitHub 凭证。",
|
||||
"cloudClaudeCodeSetup.github.desc": "让 Claude Code 继承你的 GitHub 身份,用于访问仓库和编写代码。",
|
||||
"cloudClaudeCodeSetup.github.footer": "GitHub 授权完成后,继续创建即可。",
|
||||
"cloudClaudeCodeSetup.github.title": "GitHub 授权",
|
||||
"cloudClaudeCodeSetup.title": "启用云端 Claude Code",
|
||||
"cloudClaudeCodeSetup.token.commandPrefix": "在你自己的 Claude Code 会话里运行",
|
||||
"cloudClaudeCodeSetup.token.connected": "已检测到现有 CLAUDE_CODE_OAUTH_TOKEN,会直接复用。",
|
||||
"cloudClaudeCodeSetup.token.desc": "这个 token 用于让云端 Claude Code 代表你运行。",
|
||||
"cloudClaudeCodeSetup.token.hint": "如果你还没有这个 token,请先到你自己的 Claude Code 里拿到长期有效凭证。",
|
||||
"cloudClaudeCodeSetup.token.placeholder": "粘贴 CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"cloudClaudeCodeSetup.token.title": "Claude Code 凭证",
|
||||
"codexInstallGuide.actions.openDocs": "打开安装指南",
|
||||
"codexInstallGuide.actions.openSystemTools": "打开系统工具",
|
||||
"codexInstallGuide.afterInstall": "安装完成后,请先运行一次 Codex 完成登录,然后重试刚才的消息,或在系统工具中点击“重新检测”。",
|
||||
@@ -294,6 +313,7 @@
|
||||
"minimap.senderUser": "你",
|
||||
"newAgent": "创建助理",
|
||||
"newClaudeCodeAgent": "添加 Claude Code",
|
||||
"newCloudClaudeCode": "新增云端 Claude Code",
|
||||
"newCodexAgent": "添加 Codex",
|
||||
"newGroupChat": "创建群组",
|
||||
"newPage": "创建文稿",
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface HeterogeneousProviderConfig {
|
||||
/** Custom environment variables */
|
||||
env?: Record<string, string>;
|
||||
/** Agent runtime type */
|
||||
type: 'claude-code' | 'codex';
|
||||
type: 'claude-code' | 'cloud-claude-code' | 'codex';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -98,6 +98,11 @@ export const EmojiReactionSchema = z.object({
|
||||
|
||||
export const MessageMetadataSchema = ModelUsageSchema.merge(ModelPerformanceSchema).extend({
|
||||
collapsed: z.boolean().optional(),
|
||||
cloudClaudeCodeCompletedAt: z.string().optional(),
|
||||
cloudClaudeCodeError: z.string().optional(),
|
||||
cloudClaudeCodeRunId: z.string().optional(),
|
||||
cloudClaudeCodeRunStatus: z.enum(['running', 'completed', 'failed']).optional(),
|
||||
cloudClaudeCodeStartedAt: z.string().optional(),
|
||||
inspectExpanded: z.boolean().optional(),
|
||||
isMultimodal: z.boolean().optional(),
|
||||
isSupervisor: z.boolean().optional(),
|
||||
@@ -150,6 +155,11 @@ export interface MessageMetadata {
|
||||
acceptedPredictionTokens?: number;
|
||||
activeBranchIndex?: number;
|
||||
activeColumn?: boolean;
|
||||
cloudClaudeCodeCompletedAt?: string;
|
||||
cloudClaudeCodeError?: string;
|
||||
cloudClaudeCodeRunId?: string;
|
||||
cloudClaudeCodeRunStatus?: 'running' | 'completed' | 'failed';
|
||||
cloudClaudeCodeStartedAt?: string;
|
||||
/**
|
||||
* Message collapse state
|
||||
* true: collapsed, false/undefined: expanded
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Flexbox, Text } from '@lobehub/ui';
|
||||
import { createModal, useModalContext } from '@lobehub/ui/base-ui';
|
||||
import { Alert, Input, Spin } from 'antd';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { t } from 'i18next';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SocialConnectButton from '@/layout/AuthProvider/MarketAuth/SocialConnectButton';
|
||||
import {
|
||||
type SocialProfile,
|
||||
useSocialConnect,
|
||||
} from '@/layout/AuthProvider/MarketAuth/useSocialConnect';
|
||||
import { lambdaClient } from '@/libs/trpc/client';
|
||||
|
||||
const CLAUDE_CODE_TOKEN_CRED_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
|
||||
const GITHUB_CRED_KEY = 'GITHUB';
|
||||
const GITHUB_TOKEN_CRED_KEY = 'GITHUB_TOKEN';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
actions: css`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-block-start: 24px;
|
||||
`,
|
||||
code: css`
|
||||
display: inline-flex;
|
||||
|
||||
padding-block: 2px;
|
||||
padding-inline: 6px;
|
||||
border-radius: ${cssVar.borderRadius}px;
|
||||
|
||||
font-family: ${cssVar.fontFamilyCode};
|
||||
font-size: 12px;
|
||||
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
content: css`
|
||||
padding-block: 4px 8px;
|
||||
padding-inline: 0;
|
||||
`,
|
||||
section: css`
|
||||
padding: 16px;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: ${cssVar.borderRadiusLG}px;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface CloudClaudeCodeCredItem {
|
||||
id: number;
|
||||
key: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface CloudClaudeCodeOAuthConnection {
|
||||
avatar?: string;
|
||||
email?: string;
|
||||
id: number;
|
||||
name?: string;
|
||||
providerId?: string;
|
||||
providerName?: string;
|
||||
providerUserName?: string;
|
||||
}
|
||||
|
||||
interface CloudClaudeCodeSetupState {
|
||||
claudeCodeTokenCred?: CloudClaudeCodeCredItem;
|
||||
githubConnection?: CloudClaudeCodeOAuthConnection;
|
||||
githubCred?: CloudClaudeCodeCredItem;
|
||||
}
|
||||
|
||||
const resolveGithubProfile = (
|
||||
connection?: CloudClaudeCodeOAuthConnection,
|
||||
): SocialProfile | null => {
|
||||
if (!connection) return null;
|
||||
|
||||
return {
|
||||
avatarUrl: connection.avatar,
|
||||
id: String(connection.id),
|
||||
provider: 'github',
|
||||
username:
|
||||
connection.providerUserName ||
|
||||
connection.email ||
|
||||
connection.name ||
|
||||
connection.providerName ||
|
||||
'github',
|
||||
};
|
||||
};
|
||||
|
||||
const getCloudClaudeCodeSetupState = async (): Promise<CloudClaudeCodeSetupState> => {
|
||||
const [credsResult, connectionsResult] = await Promise.all([
|
||||
lambdaClient.market.creds.list.query(),
|
||||
lambdaClient.market.creds.listOAuthConnections.query(),
|
||||
]);
|
||||
|
||||
const creds = (credsResult.data || []) as CloudClaudeCodeCredItem[];
|
||||
const connections = (connectionsResult.connections || []) as CloudClaudeCodeOAuthConnection[];
|
||||
|
||||
return {
|
||||
claudeCodeTokenCred: creds.find((cred) => cred.key === CLAUDE_CODE_TOKEN_CRED_KEY),
|
||||
githubConnection: connections.find((connection) => connection.providerId === 'github'),
|
||||
githubCred: creds.find(
|
||||
(cred) => cred.key === GITHUB_CRED_KEY || cred.key === GITHUB_TOKEN_CRED_KEY,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const ensureGithubCredential = async (connectionId: number) => {
|
||||
await lambdaClient.market.creds.createOAuth.mutate({
|
||||
key: GITHUB_CRED_KEY,
|
||||
name: 'GitHub OAuth Token',
|
||||
oauthConnectionId: connectionId,
|
||||
});
|
||||
};
|
||||
|
||||
const createClaudeCodeTokenCredential = async (token: string) => {
|
||||
await lambdaClient.market.creds.createKV.mutate({
|
||||
key: CLAUDE_CODE_TOKEN_CRED_KEY,
|
||||
name: 'Claude Code OAuth Token',
|
||||
type: 'kv-env',
|
||||
values: {
|
||||
[CLAUDE_CODE_TOKEN_CRED_KEY]: token,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
interface CloudClaudeCodeSetupModalContentProps {
|
||||
initialState: CloudClaudeCodeSetupState;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
const CloudClaudeCodeSetupModalContent = ({
|
||||
initialState,
|
||||
onCreated,
|
||||
}: CloudClaudeCodeSetupModalContentProps) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const { close } = useModalContext();
|
||||
const [setupState, setSetupState] = useState(initialState);
|
||||
const [tokenValue, setTokenValue] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
|
||||
const refreshSetupState = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
setErrorMessage(undefined);
|
||||
|
||||
try {
|
||||
const nextState = await getCloudClaudeCodeSetupState();
|
||||
setSetupState(nextState);
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : t('cloudClaudeCodeSetup.errors.refresh'),
|
||||
);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const githubConnect = useSocialConnect({
|
||||
onConnectSuccess: () => {
|
||||
void refreshSetupState();
|
||||
},
|
||||
provider: 'github',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!githubConnect.profile && setupState.githubConnection) {
|
||||
void githubConnect.fetchProfile();
|
||||
}
|
||||
}, [githubConnect, setupState.githubConnection]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!setupState.claudeCodeTokenCred && !tokenValue.trim()) {
|
||||
setErrorMessage(t('cloudClaudeCodeSetup.errors.tokenRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!setupState.githubCred && !setupState.githubConnection) {
|
||||
setErrorMessage(t('cloudClaudeCodeSetup.errors.githubRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(undefined);
|
||||
|
||||
try {
|
||||
if (!setupState.claudeCodeTokenCred) {
|
||||
await createClaudeCodeTokenCredential(tokenValue.trim());
|
||||
}
|
||||
|
||||
if (!setupState.githubCred && setupState.githubConnection) {
|
||||
await ensureGithubCredential(setupState.githubConnection.id);
|
||||
}
|
||||
|
||||
onCreated();
|
||||
close();
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : t('cloudClaudeCodeSetup.errors.submit'),
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [close, onCreated, setupState, t, tokenValue]);
|
||||
|
||||
const githubProfile = githubConnect.profile || resolveGithubProfile(setupState.githubConnection);
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.content} gap={16}>
|
||||
<Text type="secondary">{t('cloudClaudeCodeSetup.desc')}</Text>
|
||||
|
||||
{errorMessage && <Alert showIcon message={errorMessage} type="error" />}
|
||||
|
||||
<Flexbox className={styles.section} gap={12}>
|
||||
<Flexbox gap={4}>
|
||||
<Text strong>{t('cloudClaudeCodeSetup.token.title')}</Text>
|
||||
<Text type="secondary">{t('cloudClaudeCodeSetup.token.desc')}</Text>
|
||||
</Flexbox>
|
||||
|
||||
{setupState.claudeCodeTokenCred ? (
|
||||
<Alert showIcon message={t('cloudClaudeCodeSetup.token.connected')} type="success" />
|
||||
) : (
|
||||
<Flexbox gap={12}>
|
||||
<Alert showIcon message={t('cloudClaudeCodeSetup.token.hint')} type="info" />
|
||||
<Text type="secondary">
|
||||
{t('cloudClaudeCodeSetup.token.commandPrefix')}{' '}
|
||||
<span className={styles.code}>set token</span>
|
||||
</Text>
|
||||
<Input.Password
|
||||
placeholder={t('cloudClaudeCodeSetup.token.placeholder')}
|
||||
value={tokenValue}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
/>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
|
||||
<Flexbox className={styles.section} gap={12}>
|
||||
<Flexbox gap={4}>
|
||||
<Text strong>{t('cloudClaudeCodeSetup.github.title')}</Text>
|
||||
<Text type="secondary">{t('cloudClaudeCodeSetup.github.desc')}</Text>
|
||||
</Flexbox>
|
||||
|
||||
{setupState.githubCred ? (
|
||||
<Alert showIcon message={t('cloudClaudeCodeSetup.github.connected')} type="success" />
|
||||
) : setupState.githubConnection ? (
|
||||
<Alert showIcon message={t('cloudClaudeCodeSetup.github.authorized')} type="success" />
|
||||
) : isRefreshing ? (
|
||||
<Flexbox align="center" justify="center" style={{ minHeight: 48 }}>
|
||||
<Spin />
|
||||
</Flexbox>
|
||||
) : (
|
||||
<Flexbox gap={12}>
|
||||
<SocialConnectButton
|
||||
isConnecting={githubConnect.isConnecting}
|
||||
isDisconnecting={false}
|
||||
profile={githubProfile}
|
||||
provider="github"
|
||||
onConnect={githubConnect.connect}
|
||||
onDisconnect={() => undefined}
|
||||
/>
|
||||
<Text type="secondary">{t('cloudClaudeCodeSetup.github.footer')}</Text>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button onClick={close}>{t('cloudClaudeCodeSetup.actions.cancel')}</Button>
|
||||
<Button loading={isSubmitting} type="primary" onClick={() => void handleSubmit()}>
|
||||
{t('cloudClaudeCodeSetup.actions.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
export const openCloudClaudeCodeSetupModal = async (): Promise<boolean> => {
|
||||
const initialState = await getCloudClaudeCodeSetupState();
|
||||
|
||||
if (initialState.claudeCodeTokenCred && initialState.githubCred) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
initialState.claudeCodeTokenCred &&
|
||||
initialState.githubConnection &&
|
||||
!initialState.githubCred
|
||||
) {
|
||||
await ensureGithubCredential(initialState.githubConnection.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let isResolved = false;
|
||||
|
||||
const complete = (result: boolean) => {
|
||||
if (isResolved) return;
|
||||
isResolved = true;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
createModal({
|
||||
afterClose: () => complete(false),
|
||||
content: (
|
||||
<CloudClaudeCodeSetupModalContent
|
||||
initialState={initialState}
|
||||
onCreated={() => complete(true)}
|
||||
/>
|
||||
),
|
||||
footer: null,
|
||||
maskClosable: false,
|
||||
title: t('cloudClaudeCodeSetup.title', { ns: 'chat' }),
|
||||
width: 'min(92vw, 640px)',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const prepareCloudClaudeCodeSetup = async (): Promise<boolean> => {
|
||||
return openCloudClaudeCodeSetupModal();
|
||||
};
|
||||
|
||||
export type { CloudClaudeCodeSetupState };
|
||||
@@ -84,14 +84,17 @@ export const signInternalJWT = async (): Promise<string> => {
|
||||
* Used by server-side sandbox execution to authenticate CLI commands.
|
||||
* The token contains `sub: userId` and passes standard OIDC JWT validation.
|
||||
*/
|
||||
export const signUserJWT = async (userId: string): Promise<string> => {
|
||||
export const signUserJWT = async (
|
||||
userId: string,
|
||||
expirationTime: string = '5m',
|
||||
): Promise<string> => {
|
||||
const { key, kid } = await getSigningKey();
|
||||
|
||||
return new SignJWT({ purpose: 'cli-sandbox' })
|
||||
.setProtectedHeader({ alg: 'RS256', kid })
|
||||
.setSubject(userId)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('5m')
|
||||
.setExpirationTime(expirationTime)
|
||||
.sign(key);
|
||||
};
|
||||
|
||||
|
||||
@@ -317,6 +317,32 @@ export default {
|
||||
'codexInstallGuide.title': 'Install Codex CLI',
|
||||
'newAgent': 'Create Agent',
|
||||
'newClaudeCodeAgent': 'Add Claude Code',
|
||||
'cloudClaudeCodeSetup.actions.cancel': 'Cancel',
|
||||
'cloudClaudeCodeSetup.actions.confirm': 'Continue',
|
||||
'cloudClaudeCodeSetup.desc':
|
||||
'Before creating the agent, complete the Claude Code token and GitHub authorization setup.',
|
||||
'cloudClaudeCodeSetup.errors.githubRequired': 'Connect GitHub before continuing.',
|
||||
'cloudClaudeCodeSetup.errors.refresh': 'Failed to refresh the current authorization status.',
|
||||
'cloudClaudeCodeSetup.errors.submit': 'Failed to save the Cloud Claude Code prerequisites.',
|
||||
'cloudClaudeCodeSetup.errors.tokenRequired': 'Paste your CLAUDE_CODE_OAUTH_TOKEN first.',
|
||||
'cloudClaudeCodeSetup.github.authorized':
|
||||
'GitHub authorization is already available. We will create the injectable credential automatically when you continue.',
|
||||
'cloudClaudeCodeSetup.github.connected': 'Detected an available GitHub credential.',
|
||||
'cloudClaudeCodeSetup.github.desc':
|
||||
'Claude Code will use your GitHub identity to access repositories and write code for you.',
|
||||
'cloudClaudeCodeSetup.github.footer':
|
||||
'After GitHub authorization succeeds, continue creating the agent.',
|
||||
'cloudClaudeCodeSetup.github.title': 'GitHub Authorization',
|
||||
'cloudClaudeCodeSetup.title': 'Enable Cloud Claude Code',
|
||||
'cloudClaudeCodeSetup.token.commandPrefix': 'Run this inside your Claude Code session',
|
||||
'cloudClaudeCodeSetup.token.connected':
|
||||
'Detected an existing CLAUDE_CODE_OAUTH_TOKEN and will reuse it.',
|
||||
'cloudClaudeCodeSetup.token.desc': 'This token lets Cloud Claude Code run on your behalf.',
|
||||
'cloudClaudeCodeSetup.token.hint':
|
||||
'If you do not have the token yet, get the long-lived credential from your own Claude Code first.',
|
||||
'cloudClaudeCodeSetup.token.placeholder': 'Paste CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'cloudClaudeCodeSetup.token.title': 'Claude Code Token',
|
||||
'newCloudClaudeCode': 'Add Cloud Claude Code',
|
||||
'newCodexAgent': 'Add Codex',
|
||||
'newGroupChat': 'Create Group',
|
||||
'newPage': 'Create Page',
|
||||
|
||||
@@ -27,6 +27,7 @@ const Agent = memo<AgentProps>(({ itemKey }) => {
|
||||
// Create menu items
|
||||
const {
|
||||
createAgentMenuItem,
|
||||
createCloudClaudeCodeMenuItem,
|
||||
createGroupChatMenuItem,
|
||||
createHeterogeneousAgentMenuItems,
|
||||
isLoading,
|
||||
@@ -34,6 +35,7 @@ const Agent = memo<AgentProps>(({ itemKey }) => {
|
||||
|
||||
const addMenuItems = useMemo(() => {
|
||||
const heterogeneousItems = createHeterogeneousAgentMenuItems();
|
||||
const cloudCCItem = createCloudClaudeCodeMenuItem();
|
||||
|
||||
return [
|
||||
createAgentMenuItem(),
|
||||
@@ -41,8 +43,14 @@ const Agent = memo<AgentProps>(({ itemKey }) => {
|
||||
...(heterogeneousItems.length > 0
|
||||
? [{ type: 'divider' as const }, ...heterogeneousItems]
|
||||
: []),
|
||||
...(cloudCCItem ? [{ type: 'divider' as const }, cloudCCItem] : []),
|
||||
];
|
||||
}, [createAgentMenuItem, createGroupChatMenuItem, createHeterogeneousAgentMenuItems]);
|
||||
}, [
|
||||
createAgentMenuItem,
|
||||
createCloudClaudeCodeMenuItem,
|
||||
createGroupChatMenuItem,
|
||||
createHeterogeneousAgentMenuItems,
|
||||
]);
|
||||
|
||||
const handleOpenConfigGroupModal = useCallback(() => {
|
||||
openConfigGroupModal();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { HETEROGENEOUS_AGENT_CLIENT_CONFIGS } from '@lobechat/heterogeneous-agents/client';
|
||||
import {
|
||||
getHeterogeneousAgentClientConfig,
|
||||
HETEROGENEOUS_AGENT_CLIENT_CONFIGS,
|
||||
} from '@lobechat/heterogeneous-agents/client';
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { GroupBotSquareIcon } from '@lobehub/ui/icons';
|
||||
import { App } from 'antd';
|
||||
@@ -12,6 +15,7 @@ import useSWRMutation from 'swr/mutation';
|
||||
|
||||
import { useGroupTemplates } from '@/components/ChatGroupWizard/templates';
|
||||
import { DEFAULT_CHAT_GROUP_CHAT_CONFIG } from '@/const/settings';
|
||||
import { prepareCloudClaudeCodeSetup } from '@/features/CloudClaudeCode/SetupModal';
|
||||
import { useOptionalAgentModal } from '@/routes/(main)/home/_layout/Body/Agent/ModalProvider';
|
||||
import type { CreateAgentParams } from '@/services/agent';
|
||||
import type { GroupMemberConfig } from '@/services/chatGroup';
|
||||
@@ -275,6 +279,44 @@ export const useCreateMenuItems = () => {
|
||||
[t, createHeterogeneousAgent],
|
||||
);
|
||||
|
||||
/**
|
||||
* Create Cloud Claude Code agent menu item (Web only)
|
||||
*/
|
||||
const createCloudClaudeCodeMenuItem = useCallback(
|
||||
(options?: CreateAgentOptions): ItemType | null => {
|
||||
if (isDesktop) return null;
|
||||
|
||||
return {
|
||||
icon: <Icon icon={BotIcon} />,
|
||||
key: 'newCloudClaudeCode',
|
||||
label: t('newCloudClaudeCode'),
|
||||
onClick: async (info) => {
|
||||
info.domEvent?.stopPropagation();
|
||||
const isReady = await prepareCloudClaudeCodeSetup();
|
||||
if (!isReady) return;
|
||||
|
||||
const claudeCodeConfig = getHeterogeneousAgentClientConfig('claude-code');
|
||||
const result = await storeCreateAgent({
|
||||
config: {
|
||||
agencyConfig: {
|
||||
heterogeneousProvider: {
|
||||
type: 'cloud-claude-code',
|
||||
},
|
||||
},
|
||||
avatar: claudeCodeConfig?.avatar || '🤖',
|
||||
systemRole: '',
|
||||
title: 'Cloud Claude Code',
|
||||
},
|
||||
groupId: options?.groupId,
|
||||
});
|
||||
await refreshAgentList();
|
||||
navigate(`/agent/${result.agentId}`);
|
||||
},
|
||||
};
|
||||
},
|
||||
[t, storeCreateAgent, refreshAgentList, navigate],
|
||||
);
|
||||
|
||||
/**
|
||||
* Create group chat menu item
|
||||
* Creates an empty group and navigates to its profile page
|
||||
@@ -366,6 +408,7 @@ export const useCreateMenuItems = () => {
|
||||
configMenuItem,
|
||||
createAgent,
|
||||
createAgentMenuItem,
|
||||
createCloudClaudeCodeMenuItem,
|
||||
createEmptyGroup,
|
||||
createGroupChatMenuItem,
|
||||
createGroupFromTemplate,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useCreateMenuItems } from './useCreateMenuItems';
|
||||
|
||||
const createAgentMock = vi.hoisted(() =>
|
||||
vi.fn().mockResolvedValue({ agentId: 'agent-cloud-claude' }),
|
||||
);
|
||||
const refreshAgentListMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const addGroupMock = vi.hoisted(() => vi.fn());
|
||||
const switchToGroupMock = vi.hoisted(() => vi.fn());
|
||||
const createGroupMock = vi.hoisted(() => vi.fn());
|
||||
const loadGroupsMock = vi.hoisted(() => vi.fn());
|
||||
const createNewPageMock = vi.hoisted(() => vi.fn());
|
||||
const messageErrorMock = vi.hoisted(() => vi.fn());
|
||||
const navigateMock = vi.hoisted(() => vi.fn());
|
||||
const prepareCloudClaudeCodeSetupMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@lobechat/const', () => ({
|
||||
isDesktop: false,
|
||||
}));
|
||||
|
||||
vi.mock('@lobechat/heterogeneous-agents/client', () => ({
|
||||
HETEROGENEOUS_AGENT_CLIENT_CONFIGS: [],
|
||||
getHeterogeneousAgentClientConfig: vi.fn(() => ({
|
||||
avatar: 'claude-desktop-avatar',
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/ui', () => ({
|
||||
Icon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/ui/icons', () => ({
|
||||
GroupBotSquareIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
App: {
|
||||
useApp: () => ({
|
||||
message: { error: messageErrorMock },
|
||||
notification: { error: vi.fn() },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock('swr/mutation', () => ({
|
||||
default: () => ({
|
||||
isMutating: false,
|
||||
trigger: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ChatGroupWizard/templates', () => ({
|
||||
useGroupTemplates: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('@/features/CloudClaudeCode/SetupModal', () => ({
|
||||
prepareCloudClaudeCodeSetup: prepareCloudClaudeCodeSetupMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/routes/(main)/home/_layout/Body/Agent/ModalProvider', () => ({
|
||||
useOptionalAgentModal: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('@/services/chatGroup', () => ({
|
||||
chatGroupService: {
|
||||
createGroupWithMembers: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/store/agent', () => ({
|
||||
useAgentStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
createAgent: createAgentMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/agentGroup', () => ({
|
||||
useAgentGroupStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
createGroup: createGroupMock,
|
||||
loadGroups: loadGroupsMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/home', () => ({
|
||||
useHomeStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
addGroup: addGroupMock,
|
||||
refreshAgentList: refreshAgentListMock,
|
||||
switchToGroup: switchToGroupMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/page', () => ({
|
||||
usePageStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
createNewPage: createNewPageMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
const isActionItem = (
|
||||
item: unknown,
|
||||
): item is {
|
||||
key: string;
|
||||
onClick?: (info: { domEvent?: { stopPropagation?: () => void } }) => Promise<void>;
|
||||
} => !!item && typeof item === 'object' && 'key' in item;
|
||||
|
||||
describe('useCreateMenuItems (web)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates the Cloud Claude Code agent only after setup succeeds', async () => {
|
||||
prepareCloudClaudeCodeSetupMock.mockResolvedValue(true);
|
||||
|
||||
const { result } = renderHook(() => useCreateMenuItems());
|
||||
const item = result.current.createCloudClaudeCodeMenuItem();
|
||||
|
||||
if (!isActionItem(item)) {
|
||||
throw new Error('Expected Cloud Claude Code menu item');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
await item.onClick?.({ domEvent: { stopPropagation: vi.fn() } });
|
||||
});
|
||||
|
||||
expect(prepareCloudClaudeCodeSetupMock).toHaveBeenCalledTimes(1);
|
||||
expect(createAgentMock).toHaveBeenCalledWith({
|
||||
config: {
|
||||
agencyConfig: {
|
||||
heterogeneousProvider: {
|
||||
type: 'cloud-claude-code',
|
||||
},
|
||||
},
|
||||
avatar: 'claude-desktop-avatar',
|
||||
systemRole: '',
|
||||
title: 'Cloud Claude Code',
|
||||
},
|
||||
groupId: undefined,
|
||||
});
|
||||
expect(refreshAgentListMock).toHaveBeenCalled();
|
||||
expect(navigateMock).toHaveBeenCalledWith('/agent/agent-cloud-claude');
|
||||
});
|
||||
|
||||
it('does not create the Cloud Claude Code agent when setup is cancelled', async () => {
|
||||
prepareCloudClaudeCodeSetupMock.mockResolvedValue(false);
|
||||
|
||||
const { result } = renderHook(() => useCreateMenuItems());
|
||||
const item = result.current.createCloudClaudeCodeMenuItem();
|
||||
|
||||
if (!isActionItem(item)) {
|
||||
throw new Error('Expected Cloud Claude Code menu item');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
await item.onClick?.({ domEvent: { stopPropagation: vi.fn() } });
|
||||
});
|
||||
|
||||
expect(prepareCloudClaudeCodeSetupMock).toHaveBeenCalledTimes(1);
|
||||
expect(createAgentMock).not.toHaveBeenCalled();
|
||||
expect(refreshAgentListMock).not.toHaveBeenCalled();
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
/* eslint-disable no-console */
|
||||
import debug from 'debug';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { MessageModel } from '@/database/models/message';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { signUserJWT } from '@/libs/trpc/utils/internalJwt';
|
||||
import {
|
||||
buildSandboxWrapperCommand,
|
||||
CloudCCMessagePersistence,
|
||||
} from '@/server/services/cloudClaudeCode';
|
||||
import { FileService } from '@/server/services/file';
|
||||
import { MarketService } from '@/server/services/market';
|
||||
import { ServerSandboxService } from '@/server/services/sandbox';
|
||||
|
||||
const log = debug('lobe-server:cloud-claude-code-router');
|
||||
|
||||
const cloudCCProcedure = authedProcedure.use(serverDatabase);
|
||||
|
||||
const IngestSchema = z.object({
|
||||
/** Agent ID for the messages */
|
||||
agentId: z.string().optional(),
|
||||
/** Existing assistant message ID to reuse for the first ingested step */
|
||||
assistantMessageId: z.string().optional(),
|
||||
/** One complete step's worth of raw CC stream-json lines */
|
||||
lines: z.array(z.any()).min(1),
|
||||
/** Target topic ID */
|
||||
topicId: z.string(),
|
||||
});
|
||||
|
||||
const StartSchema = z.object({
|
||||
/** Agent ID */
|
||||
agentId: z.string(),
|
||||
/** Existing assistant message ID to reuse for the first ingested step */
|
||||
assistantMessageId: z.string().optional(),
|
||||
/** User prompt */
|
||||
prompt: z.string(),
|
||||
/** Resume session ID for multi-turn */
|
||||
resumeSessionId: z.string().optional(),
|
||||
/** Target topic ID */
|
||||
topicId: z.string(),
|
||||
});
|
||||
|
||||
const DebugLogSchema = z.object({
|
||||
agentId: z.string().optional(),
|
||||
payload: z.record(z.string(), z.any()),
|
||||
phase: z.string(),
|
||||
runId: z.string().optional(),
|
||||
topicId: z.string(),
|
||||
});
|
||||
|
||||
const RunStatusSchema = z.object({
|
||||
agentId: z.string().optional(),
|
||||
assistantMessageId: z.string(),
|
||||
errorMessage: z.string().optional(),
|
||||
runId: z.string().optional(),
|
||||
status: z.enum(['running', 'completed', 'failed']),
|
||||
topicId: z.string(),
|
||||
});
|
||||
|
||||
export const cloudClaudeCodeRouter = router({
|
||||
debugLog: cloudCCProcedure.input(DebugLogSchema).mutation(async ({ input }) => {
|
||||
const { topicId, agentId, runId, phase, payload } = input;
|
||||
|
||||
log(
|
||||
'debugLog: topicId=%s, agentId=%s, runId=%s, phase=%s, payload=%O',
|
||||
topicId,
|
||||
agentId,
|
||||
runId,
|
||||
phase,
|
||||
payload,
|
||||
);
|
||||
console.log(
|
||||
'[CloudCC Debug] topicId=%s agentId=%s runId=%s phase=%s payload=%s',
|
||||
topicId,
|
||||
agentId,
|
||||
runId,
|
||||
phase,
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
updateRunStatus: cloudCCProcedure.input(RunStatusSchema).mutation(async ({ input, ctx }) => {
|
||||
const { assistantMessageId, status, runId, errorMessage } = input;
|
||||
const messageModel = new MessageModel(ctx.serverDB, ctx.userId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await messageModel.updateMetadata(assistantMessageId, {
|
||||
cloudClaudeCodeCompletedAt: status === 'completed' ? now : undefined,
|
||||
cloudClaudeCodeError: errorMessage,
|
||||
cloudClaudeCodeRunId: runId,
|
||||
cloudClaudeCodeRunStatus: status,
|
||||
cloudClaudeCodeStartedAt: status === 'running' ? now : undefined,
|
||||
});
|
||||
|
||||
log('updateRunStatus: assistant=%s, status=%s, runId=%s', assistantMessageId, status, runId);
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
/**
|
||||
* Receive a batch of raw Claude Code stream-json lines (one step),
|
||||
* convert via ClaudeCodeAdapter, and persist as structured messages.
|
||||
*/
|
||||
ingest: cloudCCProcedure.input(IngestSchema).mutation(async ({ input, ctx }) => {
|
||||
const { topicId, agentId, assistantMessageId, lines } = input;
|
||||
|
||||
log('ingest: topicId=%s, agentId=%s, lines=%d', topicId, agentId, lines.length);
|
||||
|
||||
const persistence = new CloudCCMessagePersistence(
|
||||
ctx.serverDB,
|
||||
ctx.userId,
|
||||
topicId,
|
||||
agentId,
|
||||
assistantMessageId,
|
||||
);
|
||||
|
||||
const result = await persistence.processBatch(lines);
|
||||
|
||||
log(
|
||||
'ingest done: assistantMsg=%s, toolMsgs=%d, sessionId=%s',
|
||||
result.assistantMessageId,
|
||||
result.toolMessageIds.length,
|
||||
result.sessionId,
|
||||
);
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Start a Cloud Claude Code session in the sandbox.
|
||||
* Generates JWT, builds wrapper command, and invokes sandbox runCommand.
|
||||
*/
|
||||
start: cloudCCProcedure.input(StartSchema).mutation(async ({ input, ctx }) => {
|
||||
const { topicId, agentId, assistantMessageId, prompt, resumeSessionId } = input;
|
||||
|
||||
console.log(
|
||||
'[CloudCC Server] start: topicId=%s, agentId=%s, prompt=%s',
|
||||
topicId,
|
||||
agentId,
|
||||
prompt.slice(0, 80),
|
||||
);
|
||||
|
||||
// 1. Generate short-lived JWT for sandbox → server callback
|
||||
const jwt = await signUserJWT(ctx.userId, '2h');
|
||||
// FIXME: hardcoded tunnel URL for local testing — revert before merge
|
||||
const serverUrl = 'https://sonic-fridge-detected-interested.trycloudflare.com';
|
||||
console.log('[CloudCC Server] serverUrl:', serverUrl);
|
||||
|
||||
// 2. Build the inline wrapper command
|
||||
const wrapperCommand = buildSandboxWrapperCommand({
|
||||
agentId,
|
||||
assistantMessageId,
|
||||
prompt,
|
||||
resumeSessionId,
|
||||
topicId,
|
||||
});
|
||||
|
||||
// 3. Build the full command with env vars injected
|
||||
const envPrefix = [
|
||||
`LOBEHUB_JWT=${jwt}`,
|
||||
`LOBEHUB_SERVER=${serverUrl}`,
|
||||
'LOBEHUB_CLOUD_CC_DEBUG=1',
|
||||
'GITHUB_TOKEN=${GITHUB_TOKEN:-$GITHUB_ACCESS_TOKEN}',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const fullCommand = `${envPrefix} ${wrapperCommand}`;
|
||||
|
||||
log('start: command length=%d', fullCommand.length);
|
||||
|
||||
// 4. Call sandbox runCommand (fire-and-forget)
|
||||
const marketService = new MarketService({ userInfo: { userId: ctx.userId } });
|
||||
const fileService = new FileService(ctx.serverDB, ctx.userId);
|
||||
const sandboxService = new ServerSandboxService({
|
||||
fileService,
|
||||
marketService,
|
||||
topicId,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
|
||||
// 4a. Inject Claude Code auth first. Mixing GitHub creds into the same
|
||||
// inject call can interfere with Claude CLI login state in the sandbox.
|
||||
console.log('[CloudCC Server] injecting CLAUDE_CODE_OAUTH_TOKEN to sandbox...');
|
||||
try {
|
||||
const result = await marketService.market.creds.inject({
|
||||
keys: ['CLAUDE_CODE_OAUTH_TOKEN'],
|
||||
sandbox: true,
|
||||
topicId,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
console.log('[CloudCC Server] creds injected OK, notFound:', result.notFound);
|
||||
} catch (e) {
|
||||
console.error('[CloudCC Server] creds injection failed (CC may not be authenticated):', e);
|
||||
}
|
||||
|
||||
// 4b. Inject GitHub creds separately so a missing/extra GitHub key does
|
||||
// not affect Claude Code's own auth bootstrap.
|
||||
console.log('[CloudCC Server] injecting GitHub creds to sandbox...');
|
||||
try {
|
||||
const result = await marketService.market.creds.inject({
|
||||
keys: ['GITHUB', 'GITHUB_TOKEN'],
|
||||
sandbox: true,
|
||||
topicId,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
console.log('[CloudCC Server] GitHub creds injected OK, notFound:', result.notFound);
|
||||
} catch (e) {
|
||||
console.error('[CloudCC Server] GitHub creds injection failed:', e);
|
||||
}
|
||||
|
||||
console.log('[CloudCC Server] calling sandbox runCommand, command length:', fullCommand.length);
|
||||
|
||||
// Await sandbox runCommand — blocks until CC finishes.
|
||||
// Frontend calls this mutation and uses .finally() to know when CC is done.
|
||||
const sandboxResult = await sandboxService.callTool('runCommand', { command: fullCommand });
|
||||
console.log(
|
||||
'[CloudCC Server] sandbox runCommand result:',
|
||||
JSON.stringify(sandboxResult).slice(0, 500),
|
||||
);
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
topicId,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import { apiKeyRouter } from './apiKey';
|
||||
import { botMessageRouter } from './botMessage';
|
||||
import { briefRouter } from './brief';
|
||||
import { chunkRouter } from './chunk';
|
||||
import { cloudClaudeCodeRouter } from './cloudClaudeCode';
|
||||
import { comfyuiRouter } from './comfyui';
|
||||
import { configRouter } from './config';
|
||||
import { deviceRouter } from './device';
|
||||
@@ -67,6 +68,7 @@ export const lambdaRouter = router({
|
||||
agent: agentRouter,
|
||||
agentBotProvider: agentBotProviderRouter,
|
||||
agentNotify: agentNotifyRouter,
|
||||
cloudClaudeCode: cloudClaudeCodeRouter,
|
||||
botMessage: botMessageRouter,
|
||||
agentCronJob: agentCronJobRouter,
|
||||
agentDocument: agentDocumentRouter,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CloudCCMessagePersistence } from './messagePersistence';
|
||||
export { buildSandboxWrapperCommand } from './sandboxWrapper';
|
||||
@@ -0,0 +1,263 @@
|
||||
// @vitest-environment node
|
||||
import { createAdapter } from '@lobechat/heterogeneous-agents';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MessageModel } from '@/database/models/message';
|
||||
|
||||
import { CloudCCMessagePersistence } from './messagePersistence';
|
||||
|
||||
vi.mock('@lobechat/heterogeneous-agents', () => ({
|
||||
createAdapter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/message', () => ({
|
||||
MessageModel: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('CloudCCMessagePersistence', () => {
|
||||
const mockCreate = vi.fn();
|
||||
const mockQuery = vi.fn();
|
||||
const mockUpdate = vi.fn();
|
||||
const mockUpdateMetadata = vi.fn();
|
||||
const mockUpdatePluginState = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(MessageModel).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
create: mockCreate,
|
||||
query: mockQuery,
|
||||
update: mockUpdate,
|
||||
updateMetadata: mockUpdateMetadata,
|
||||
updatePluginState: mockUpdatePluginState,
|
||||
}) as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses the existing assistant message for the first ingested step', async () => {
|
||||
mockQuery.mockResolvedValue([]);
|
||||
|
||||
vi.mocked(createAdapter).mockReturnValue({
|
||||
adapt: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
data: { model: 'claude-sonnet-4-6', provider: 'cloud-claude-code' },
|
||||
type: 'stream_start',
|
||||
},
|
||||
])
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
data: { chunkType: 'text', content: 'Hello from Cloud CC' },
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
]),
|
||||
flush: vi.fn().mockReturnValue([]),
|
||||
sessionId: 'cc-session-1',
|
||||
} as any);
|
||||
|
||||
const persistence = new CloudCCMessagePersistence(
|
||||
{} as any,
|
||||
'user-1',
|
||||
'topic-1',
|
||||
'agent-1',
|
||||
'assistant-existing',
|
||||
);
|
||||
|
||||
const result = await persistence.processBatch([{ type: 'assistant' }, { type: 'assistant' }]);
|
||||
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
expect(mockUpdate).toHaveBeenCalledWith('assistant-existing', {
|
||||
content: 'Hello from Cloud CC',
|
||||
model: 'claude-sonnet-4-6',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
assistantMessageId: 'assistant-existing',
|
||||
sessionId: 'cc-session-1',
|
||||
toolMessageIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores subagent-only batches so they do not mutate the main assistant', async () => {
|
||||
mockQuery.mockResolvedValue([]);
|
||||
|
||||
vi.mocked(createAdapter).mockReturnValue({
|
||||
adapt: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
data: {
|
||||
chunkType: 'text',
|
||||
content: 'subagent summary',
|
||||
subagent: {
|
||||
parentToolCallId: 'toolu-parent',
|
||||
subagentMessageId: 'msg-sub-1',
|
||||
},
|
||||
},
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
])
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
data: {
|
||||
content: 'tool result',
|
||||
subagent: { parentToolCallId: 'toolu-parent' },
|
||||
toolCallId: 'toolu-child',
|
||||
},
|
||||
type: 'tool_result',
|
||||
},
|
||||
]),
|
||||
flush: vi.fn().mockReturnValue([]),
|
||||
sessionId: 'cc-session-subagent',
|
||||
} as any);
|
||||
|
||||
const persistence = new CloudCCMessagePersistence(
|
||||
{} as any,
|
||||
'user-1',
|
||||
'topic-1',
|
||||
'agent-1',
|
||||
'assistant-existing',
|
||||
);
|
||||
|
||||
const result = await persistence.processBatch([{ type: 'assistant' }, { type: 'user' }]);
|
||||
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
expect(mockUpdateMetadata).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
assistantMessageId: 'assistant-existing',
|
||||
sessionId: 'cc-session-subagent',
|
||||
toolMessageIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('chains later main-agent steps after the latest persisted message', async () => {
|
||||
mockQuery.mockResolvedValue([{ id: 'assistant-prev' }, { id: 'tool-prev' }]);
|
||||
|
||||
mockCreate.mockResolvedValue({ id: 'assistant-step-2' });
|
||||
|
||||
vi.mocked(createAdapter).mockReturnValue({
|
||||
adapt: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
data: { model: 'claude-sonnet-4-6', provider: 'cloud-claude-code' },
|
||||
type: 'stream_start',
|
||||
},
|
||||
])
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
data: { chunkType: 'text', content: 'Final answer' },
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
]),
|
||||
flush: vi.fn().mockReturnValue([]),
|
||||
sessionId: 'cc-session-step-2',
|
||||
} as any);
|
||||
|
||||
const persistence = new CloudCCMessagePersistence({} as any, 'user-1', 'topic-1', 'agent-1');
|
||||
|
||||
const result = await persistence.processBatch([{ type: 'assistant' }, { type: 'assistant' }]);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
agentId: 'agent-1',
|
||||
content: '',
|
||||
model: 'claude-sonnet-4-6',
|
||||
parentId: 'tool-prev',
|
||||
provider: 'cloud-claude-code',
|
||||
role: 'assistant',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
expect(mockUpdate).toHaveBeenCalledWith('assistant-step-2', {
|
||||
content: 'Final answer',
|
||||
model: 'claude-sonnet-4-6',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
assistantMessageId: 'assistant-step-2',
|
||||
sessionId: 'cc-session-step-2',
|
||||
toolMessageIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('pre-registers tools on the assistant before creating tool rows', async () => {
|
||||
mockQuery.mockResolvedValue([]);
|
||||
|
||||
mockCreate
|
||||
.mockResolvedValueOnce({ id: 'assistant-step-1' })
|
||||
.mockResolvedValueOnce({ id: 'tool-msg-1' });
|
||||
|
||||
vi.mocked(createAdapter).mockReturnValue({
|
||||
adapt: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
data: { model: 'claude-sonnet-4-6', provider: 'cloud-claude-code' },
|
||||
type: 'stream_start',
|
||||
},
|
||||
])
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
data: {
|
||||
chunkType: 'tools_calling',
|
||||
toolsCalling: [
|
||||
{
|
||||
apiName: 'WebFetch',
|
||||
arguments: '{"url":"https://github.com/lobehub/lobehub"}',
|
||||
id: 'tool-call-1',
|
||||
identifier: 'claude-code',
|
||||
type: 'default',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
])
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
data: {
|
||||
toolCalling: {
|
||||
apiName: 'WebFetch',
|
||||
arguments: '{"url":"https://github.com/lobehub/lobehub"}',
|
||||
id: 'tool-call-1',
|
||||
identifier: 'claude-code',
|
||||
type: 'default',
|
||||
},
|
||||
},
|
||||
type: 'tool_start',
|
||||
},
|
||||
]),
|
||||
flush: vi.fn().mockReturnValue([]),
|
||||
sessionId: 'cc-session-tools',
|
||||
} as any);
|
||||
|
||||
await new CloudCCMessagePersistence({} as any, 'user-1', 'topic-1', 'agent-1').processBatch([
|
||||
{ type: 'assistant' },
|
||||
{ type: 'assistant' },
|
||||
{ type: 'assistant' },
|
||||
]);
|
||||
|
||||
expect(mockUpdate).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'assistant-step-1',
|
||||
expect.objectContaining({
|
||||
model: 'claude-sonnet-4-6',
|
||||
tools: [
|
||||
expect.objectContaining({
|
||||
apiName: 'WebFetch',
|
||||
id: 'tool-call-1',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(mockCreate).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
parentId: 'assistant-step-1',
|
||||
role: 'tool',
|
||||
tool_call_id: 'tool-call-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import type {
|
||||
HeterogeneousAgentEvent,
|
||||
StreamChunkData,
|
||||
ToolCallPayload,
|
||||
ToolResultData,
|
||||
} from '@lobechat/heterogeneous-agents';
|
||||
import { createAdapter } from '@lobechat/heterogeneous-agents';
|
||||
import type { ChatToolPayload } from '@lobechat/types';
|
||||
|
||||
import { MessageModel } from '@/database/models/message';
|
||||
|
||||
/**
|
||||
* Server-side persistence for Cloud Claude Code.
|
||||
*
|
||||
* Each `processBatch` call handles one complete step's worth of raw
|
||||
* stream-json lines. A fresh ClaudeCodeAdapter is created per call,
|
||||
* so no cross-request state is needed.
|
||||
*
|
||||
* The adapter converts raw CC CLI NDJSON lines into HeterogeneousAgentEvent[],
|
||||
* and this class maps those events to DB writes (assistant + tool messages).
|
||||
*/
|
||||
export class CloudCCMessagePersistence {
|
||||
private messageModel: MessageModel;
|
||||
private initialAssistantMessageId?: string;
|
||||
|
||||
private findLatestMessageId = async () => {
|
||||
const messages = await this.messageModel.query({
|
||||
agentId: this.agentId,
|
||||
topicId: this.topicId,
|
||||
});
|
||||
|
||||
return messages.at(-1)?.id;
|
||||
};
|
||||
|
||||
private updateAssistantSnapshot = async (params: {
|
||||
assistantMessageId: string;
|
||||
content: string;
|
||||
model?: string;
|
||||
reasoning: string;
|
||||
tools: ChatToolPayload[];
|
||||
}) => {
|
||||
const { assistantMessageId, content, model, reasoning, tools } = params;
|
||||
const updatePayload: Record<string, any> = {};
|
||||
|
||||
if (content) updatePayload.content = content;
|
||||
if (model) updatePayload.model = model;
|
||||
if (tools.length > 0) updatePayload.tools = tools;
|
||||
|
||||
if (Object.keys(updatePayload).length > 0) {
|
||||
await this.messageModel.update(assistantMessageId, updatePayload);
|
||||
}
|
||||
|
||||
if (reasoning) {
|
||||
await this.messageModel.updateMetadata(assistantMessageId, {
|
||||
reasoning: { content: reasoning },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly serverDB: any,
|
||||
private readonly userId: string,
|
||||
private readonly topicId: string,
|
||||
private readonly agentId?: string,
|
||||
initialAssistantMessageId?: string,
|
||||
) {
|
||||
this.messageModel = new MessageModel(serverDB, userId);
|
||||
this.initialAssistantMessageId = initialAssistantMessageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch of raw stream-json lines (one complete CC step).
|
||||
*
|
||||
* Flow: raw lines → ClaudeCodeAdapter.adapt() → HeterogeneousAgentEvent[] → DB writes
|
||||
*/
|
||||
async processBatch(rawLines: any[]): Promise<{
|
||||
assistantMessageId?: string;
|
||||
sessionId?: string;
|
||||
toolMessageIds: string[];
|
||||
}> {
|
||||
// 1. Create a fresh adapter per batch (stateless across requests)
|
||||
const adapter = createAdapter('claude-code');
|
||||
|
||||
// 2. Feed all lines through the adapter
|
||||
const events: HeterogeneousAgentEvent[] = [];
|
||||
for (const line of rawLines) {
|
||||
events.push(...adapter.adapt(line));
|
||||
}
|
||||
events.push(...adapter.flush());
|
||||
|
||||
// 3. Process events into DB writes
|
||||
let assistantMessageId = this.initialAssistantMessageId;
|
||||
let content = '';
|
||||
let reasoning = '';
|
||||
const tools: ChatToolPayload[] = [];
|
||||
const toolMessageIds: string[] = [];
|
||||
// Map toolCallId → tool message DB id, for updating tool_result
|
||||
const toolMsgIdByCallId = new Map<string, string>();
|
||||
let hasMainAssistantActivity = false;
|
||||
let model: string | undefined;
|
||||
let provider: string | undefined;
|
||||
|
||||
for (const event of events) {
|
||||
switch (event.type) {
|
||||
case 'stream_start': {
|
||||
const data = event.data as { model?: string; provider?: string };
|
||||
model = data.model;
|
||||
provider = data.provider || 'cloud-claude-code';
|
||||
|
||||
if (!assistantMessageId) {
|
||||
const parentId = await this.findLatestMessageId();
|
||||
const msg = await this.messageModel.create({
|
||||
agentId: this.agentId,
|
||||
content: '',
|
||||
model,
|
||||
parentId,
|
||||
provider,
|
||||
role: 'assistant',
|
||||
topicId: this.topicId,
|
||||
});
|
||||
assistantMessageId = msg.id;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream_chunk': {
|
||||
const chunk = event.data as StreamChunkData;
|
||||
if (chunk.subagent) break;
|
||||
|
||||
if (chunk.chunkType === 'text' && chunk.content) {
|
||||
hasMainAssistantActivity = true;
|
||||
content += chunk.content;
|
||||
}
|
||||
|
||||
if (chunk.chunkType === 'reasoning' && chunk.reasoning) {
|
||||
reasoning += chunk.reasoning;
|
||||
}
|
||||
|
||||
// tools_calling: register tool calls on the assistant message
|
||||
if (chunk.chunkType === 'tools_calling' && chunk.toolsCalling) {
|
||||
hasMainAssistantActivity = true;
|
||||
let hasFreshTool = false;
|
||||
for (const tc of chunk.toolsCalling) {
|
||||
// Only add if not already tracked
|
||||
if (!tools.some((t) => t.id === tc.id)) {
|
||||
hasFreshTool = true;
|
||||
tools.push({
|
||||
apiName: tc.apiName,
|
||||
arguments: tc.arguments,
|
||||
id: tc.id,
|
||||
identifier: tc.identifier,
|
||||
type: tc.type as ChatToolPayload['type'],
|
||||
});
|
||||
} else {
|
||||
// Update arguments for existing tool (streaming partial → complete)
|
||||
const existing = tools.find((t) => t.id === tc.id);
|
||||
if (existing) {
|
||||
existing.arguments = tc.arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (assistantMessageId && hasFreshTool) {
|
||||
await this.updateAssistantSnapshot({
|
||||
assistantMessageId,
|
||||
content,
|
||||
model,
|
||||
reasoning,
|
||||
tools,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool_start': {
|
||||
const { toolCalling, subagent } = event.data as {
|
||||
subagent?: any;
|
||||
toolCalling: ToolCallPayload;
|
||||
};
|
||||
|
||||
// Skip subagent tools for now (future iteration)
|
||||
if (subagent) break;
|
||||
if (!toolCalling) break;
|
||||
|
||||
// Create tool message
|
||||
const toolMsg = await this.messageModel.create({
|
||||
agentId: this.agentId,
|
||||
content: '',
|
||||
parentId: assistantMessageId,
|
||||
plugin: {
|
||||
apiName: toolCalling.apiName,
|
||||
arguments: toolCalling.arguments || '',
|
||||
identifier: toolCalling.identifier,
|
||||
type: (toolCalling.type || 'default') as ChatToolPayload['type'],
|
||||
},
|
||||
role: 'tool',
|
||||
tool_call_id: toolCalling.id,
|
||||
topicId: this.topicId,
|
||||
});
|
||||
|
||||
toolMessageIds.push(toolMsg.id);
|
||||
toolMsgIdByCallId.set(toolCalling.id, toolMsg.id);
|
||||
|
||||
// Ensure this tool is in the assistant's tools array
|
||||
if (!tools.some((t) => t.id === toolCalling.id)) {
|
||||
tools.push({
|
||||
apiName: toolCalling.apiName,
|
||||
arguments: toolCalling.arguments || '',
|
||||
id: toolCalling.id,
|
||||
identifier: toolCalling.identifier,
|
||||
type: (toolCalling.type || 'default') as ChatToolPayload['type'],
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool_result': {
|
||||
const {
|
||||
toolCallId,
|
||||
content: resultContent,
|
||||
pluginState,
|
||||
subagent,
|
||||
} = event.data as ToolResultData;
|
||||
|
||||
// Skip subagent tool results for now
|
||||
if (subagent) break;
|
||||
|
||||
const toolMsgId = toolMsgIdByCallId.get(toolCallId);
|
||||
if (toolMsgId) {
|
||||
// Update tool message with result content
|
||||
await this.messageModel.update(toolMsgId, { content: resultContent || '' });
|
||||
|
||||
// Update pluginState if present
|
||||
if (pluginState) {
|
||||
await this.messageModel.updatePluginState(toolMsgId, pluginState);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'step_complete': {
|
||||
const stepData = event.data as { model?: string; usage?: Record<string, unknown> };
|
||||
// Update usage metadata on the assistant message
|
||||
if (assistantMessageId && stepData.usage) {
|
||||
await this.messageModel.updateMetadata(assistantMessageId, {
|
||||
usage: stepData.usage,
|
||||
});
|
||||
}
|
||||
if (stepData.model) {
|
||||
model = stepData.model;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream_end':
|
||||
case 'agent_runtime_end': {
|
||||
// Finalize: handled after the loop
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
hasMainAssistantActivity = true;
|
||||
// Persist error on assistant message
|
||||
if (assistantMessageId) {
|
||||
const errorData = event.data as { message?: string };
|
||||
await this.messageModel.update(assistantMessageId, {
|
||||
error: {
|
||||
body: { message: errorData.message || 'Cloud Claude Code error' },
|
||||
type: 'AgentRuntimeError',
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Finalize assistant message with accumulated content + tools
|
||||
if (assistantMessageId && hasMainAssistantActivity) {
|
||||
// Write tools with result_msg_id backfilled
|
||||
if (tools.length > 0) {
|
||||
const toolsWithResultIds = tools.map((t) => ({
|
||||
...t,
|
||||
result_msg_id: toolMsgIdByCallId.get(t.id),
|
||||
}));
|
||||
await this.updateAssistantSnapshot({
|
||||
assistantMessageId,
|
||||
content,
|
||||
model,
|
||||
reasoning,
|
||||
tools: toolsWithResultIds,
|
||||
});
|
||||
} else {
|
||||
await this.updateAssistantSnapshot({
|
||||
assistantMessageId,
|
||||
content,
|
||||
model,
|
||||
reasoning,
|
||||
tools,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
assistantMessageId,
|
||||
sessionId: adapter.sessionId,
|
||||
toolMessageIds,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSandboxWrapperCommand } from './sandboxWrapper';
|
||||
|
||||
describe('buildSandboxWrapperCommand', () => {
|
||||
it('cuts main-agent batches on stream_event message_start boundaries', () => {
|
||||
const command = buildSandboxWrapperCommand({
|
||||
agentId: 'agent-1',
|
||||
prompt: 'summarize this repo',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
expect(command).toContain('streamEventMessageId');
|
||||
expect(command).toContain('message_start');
|
||||
expect(command).toContain('nextMainMessageId = streamEventMessageId || assistantMessageId');
|
||||
expect(command).toContain('let processing = Promise.resolve()');
|
||||
expect(command).toContain('then(() => processLine(raw))');
|
||||
});
|
||||
|
||||
it('posts structured debug logs when debug mode is enabled', () => {
|
||||
const command = buildSandboxWrapperCommand({
|
||||
agentId: 'agent-1',
|
||||
assistantMessageId: 'assistant-1',
|
||||
prompt: 'summarize this repo',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
expect(command).toContain('LOBEHUB_CLOUD_CC_DEBUG');
|
||||
expect(command).toContain('function postDebug(phase, payload)');
|
||||
expect(command).toContain('/trpc/lambda/cloudClaudeCode.debugLog');
|
||||
expect(command).toContain('/trpc/lambda/cloudClaudeCode.updateRunStatus');
|
||||
expect(command).toContain('postRunStatus');
|
||||
expect(command).toContain('completed');
|
||||
expect(command).toContain('boundary');
|
||||
expect(command).toContain('flush:start');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Generate the inline Node.js script that runs inside the sandbox.
|
||||
*
|
||||
* The script:
|
||||
* 1. Spawns `claude` CLI with stream-json output
|
||||
* 2. Reads stdout line by line
|
||||
* 3. Detects step boundaries (assistant message.id changes)
|
||||
* 4. POSTs each step's lines to the TRPC ingest endpoint via curl-style HTTP
|
||||
*
|
||||
* The script is injected via `runCommand` as `node -e "<script>"`.
|
||||
* Environment variables (LOBEHUB_JWT, LOBEHUB_SERVER, CLAUDE_CODE_OAUTH_TOKEN)
|
||||
* are injected by preprocessLhCommand or directly via runCommand env.
|
||||
*/
|
||||
export function buildSandboxWrapperCommand(params: {
|
||||
agentId: string;
|
||||
assistantMessageId?: string;
|
||||
prompt: string;
|
||||
resumeSessionId?: string;
|
||||
topicId: string;
|
||||
}): string {
|
||||
const { topicId, agentId, assistantMessageId, prompt, resumeSessionId } = params;
|
||||
|
||||
const escapeForSingleQuotedJs = (value: string) =>
|
||||
value.replaceAll('\\', '\\\\').replaceAll("'", "\\'");
|
||||
|
||||
// Escape single quotes in prompt for safe embedding in JS string
|
||||
const escapedPrompt = escapeForSingleQuotedJs(prompt);
|
||||
const escapedAgentId = escapeForSingleQuotedJs(agentId);
|
||||
const escapedAssistantMessageId = assistantMessageId
|
||||
? escapeForSingleQuotedJs(assistantMessageId)
|
||||
: '';
|
||||
const escapedResumeSessionId = resumeSessionId
|
||||
? escapeForSingleQuotedJs(resumeSessionId)
|
||||
: undefined;
|
||||
const escapedTopicId = escapeForSingleQuotedJs(topicId);
|
||||
|
||||
const resumeArgs = escapedResumeSessionId ? `'--resume', '${escapedResumeSessionId}',` : '';
|
||||
|
||||
// The inline Node.js script that runs inside the sandbox
|
||||
const script = `
|
||||
const { spawn } = require('child_process');
|
||||
const { createInterface } = require('readline');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
const SERVER = process.env.LOBEHUB_SERVER || 'https://app.lobehub.com';
|
||||
const JWT = process.env.LOBEHUB_JWT || '';
|
||||
const DEBUG_ENABLED = process.env.LOBEHUB_CLOUD_CC_DEBUG === '1';
|
||||
const TOPIC_ID = '${escapedTopicId}';
|
||||
const AGENT_ID = '${escapedAgentId}';
|
||||
const INITIAL_ASSISTANT_MESSAGE_ID = '${escapedAssistantMessageId}';
|
||||
const RUN_ID = [
|
||||
TOPIC_ID,
|
||||
AGENT_ID,
|
||||
INITIAL_ASSISTANT_MESSAGE_ID || 'no-assistant',
|
||||
String(Date.now()),
|
||||
].join(':');
|
||||
|
||||
function postTrpc(path, input) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = JSON.stringify({
|
||||
json: input,
|
||||
});
|
||||
const url = new URL(SERVER + path);
|
||||
const mod = url.protocol === 'https:' ? https : http;
|
||||
const req = mod.request({
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Oidc-Auth': JWT,
|
||||
},
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (d) => data += d);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 400) {
|
||||
console.error('POST failed:', path, res.statusCode, data.slice(0, 200));
|
||||
}
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => { console.error('POST error:', path, e.message); resolve(''); });
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function post(lines, assistantMessageId) {
|
||||
return postTrpc('/trpc/lambda/cloudClaudeCode.ingest', {
|
||||
topicId: TOPIC_ID,
|
||||
agentId: AGENT_ID,
|
||||
...(assistantMessageId ? { assistantMessageId } : {}),
|
||||
lines,
|
||||
});
|
||||
}
|
||||
|
||||
async function postDebug(phase, payload) {
|
||||
if (!DEBUG_ENABLED) return;
|
||||
|
||||
await postTrpc('/trpc/lambda/cloudClaudeCode.debugLog', {
|
||||
topicId: TOPIC_ID,
|
||||
agentId: AGENT_ID,
|
||||
phase,
|
||||
payload,
|
||||
runId: RUN_ID,
|
||||
});
|
||||
}
|
||||
|
||||
async function postRunStatus(status, errorMessage) {
|
||||
if (!INITIAL_ASSISTANT_MESSAGE_ID) return;
|
||||
|
||||
await postTrpc('/trpc/lambda/cloudClaudeCode.updateRunStatus', {
|
||||
topicId: TOPIC_ID,
|
||||
agentId: AGENT_ID,
|
||||
assistantMessageId: INITIAL_ASSISTANT_MESSAGE_ID,
|
||||
...(errorMessage ? { errorMessage } : {}),
|
||||
runId: RUN_ID,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
const args = [
|
||||
'-p', '${escapedPrompt}',
|
||||
'--output-format', 'stream-json',
|
||||
'--verbose',
|
||||
'--include-partial-messages',
|
||||
'--allowedTools', 'WebFetch,WebSearch',
|
||||
'--permission-mode', 'acceptEdits',
|
||||
${resumeArgs}
|
||||
];
|
||||
|
||||
const child = spawn('claude', args, {
|
||||
env: { ...process.env },
|
||||
stdio: ['inherit', 'pipe', 'inherit'],
|
||||
});
|
||||
|
||||
const rl = createInterface({ input: child.stdout });
|
||||
let buffer = [];
|
||||
let curMsgId;
|
||||
let initialAssistantMessageId = INITIAL_ASSISTANT_MESSAGE_ID || undefined;
|
||||
let stepCount = 0;
|
||||
let processing = Promise.resolve();
|
||||
|
||||
async function flush(lines) {
|
||||
if (!lines.length) return;
|
||||
stepCount++;
|
||||
const assistantMessageId = initialAssistantMessageId;
|
||||
initialAssistantMessageId = undefined;
|
||||
await postDebug('flush:start', {
|
||||
assistantMessageId: assistantMessageId || null,
|
||||
bufferLength: lines.length,
|
||||
firstLineType: lines[0]?.type || null,
|
||||
lastLineType: lines[lines.length - 1]?.type || null,
|
||||
stepCount,
|
||||
});
|
||||
await post(lines, assistantMessageId);
|
||||
await postDebug('flush:done', {
|
||||
assistantMessageId: assistantMessageId || null,
|
||||
bufferLength: lines.length,
|
||||
stepCount,
|
||||
});
|
||||
console.error('Step ' + stepCount + ': ' + lines.length + ' events posted');
|
||||
}
|
||||
|
||||
async function processLine(raw) {
|
||||
let line;
|
||||
try { line = JSON.parse(raw); } catch { return; }
|
||||
const streamEventMessageId =
|
||||
line.type === 'stream_event' &&
|
||||
!line.parent_tool_use_id &&
|
||||
line.event?.type === 'message_start' &&
|
||||
line.event?.message?.id
|
||||
? line.event.message.id
|
||||
: undefined;
|
||||
const isSubagentAssistant =
|
||||
line.type === 'assistant' &&
|
||||
!!line.parent_tool_use_id &&
|
||||
line.message &&
|
||||
line.message.id;
|
||||
const assistantMessageId =
|
||||
!isSubagentAssistant &&
|
||||
line.type === 'assistant' &&
|
||||
line.message &&
|
||||
line.message.id
|
||||
? line.message.id
|
||||
: undefined;
|
||||
const nextMainMessageId = streamEventMessageId || assistantMessageId;
|
||||
const eventType =
|
||||
line.type === 'stream_event' && line.event && line.event.type
|
||||
? line.event.type
|
||||
: undefined;
|
||||
|
||||
await postDebug('line', {
|
||||
assistantMessageId: assistantMessageId || null,
|
||||
bufferLength: buffer.length,
|
||||
curMsgId: curMsgId || null,
|
||||
eventType: eventType || null,
|
||||
isSubagentAssistant,
|
||||
lineType: line.type || null,
|
||||
nextMainMessageId: nextMainMessageId || null,
|
||||
parentToolUseId: line.parent_tool_use_id || null,
|
||||
stepCount,
|
||||
streamEventMessageId: streamEventMessageId || null,
|
||||
});
|
||||
|
||||
if (nextMainMessageId) {
|
||||
if (curMsgId && nextMainMessageId !== curMsgId) {
|
||||
const prev = buffer;
|
||||
buffer = [line];
|
||||
await postDebug('boundary', {
|
||||
flushedBufferLength: prev.length,
|
||||
fromMessageId: curMsgId,
|
||||
nextMainMessageId,
|
||||
stepCount,
|
||||
});
|
||||
await flush(prev);
|
||||
} else {
|
||||
buffer.push(line);
|
||||
}
|
||||
curMsgId = nextMainMessageId;
|
||||
} else {
|
||||
buffer.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
rl.on('line', (raw) => {
|
||||
processing = processing
|
||||
.then(() => processLine(raw))
|
||||
.catch((error) => {
|
||||
console.error('Line processing error:', error && error.message ? error.message : error);
|
||||
});
|
||||
});
|
||||
|
||||
child.on('close', () => {
|
||||
processing = processing
|
||||
.then(async () => {
|
||||
await postDebug('close:start', {
|
||||
bufferLength: buffer.length,
|
||||
curMsgId: curMsgId || null,
|
||||
stepCount,
|
||||
});
|
||||
await flush(buffer);
|
||||
await postRunStatus('completed');
|
||||
await postDebug('close:done', {
|
||||
curMsgId: curMsgId || null,
|
||||
stepCount,
|
||||
});
|
||||
console.error('Done: ' + stepCount + ' step(s)');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Close processing error:', error && error.message ? error.message : error);
|
||||
});
|
||||
});
|
||||
`.trim();
|
||||
|
||||
// Return as node -e command
|
||||
return `node -e '${script.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-console */
|
||||
// Disable the auto sort key eslint rule to make the code more logic and readable
|
||||
import { createCallAgentManifest } from '@lobechat/builtin-tool-agent-management';
|
||||
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
|
||||
@@ -388,10 +389,197 @@ export class ConversationLifecycleActionImpl {
|
||||
inputSendErrorMsg: undefined,
|
||||
});
|
||||
|
||||
// ── External agent mode: delegate to heterogeneous agent CLI (desktop only) ──
|
||||
// ── External agent mode ──
|
||||
// Per-agent heterogeneousProvider config takes priority over the global gateway mode.
|
||||
const agentConfig = agentSelectors.getAgentConfigById(agentId)(getAgentStoreState());
|
||||
const heterogeneousProvider = agentConfig?.agencyConfig?.heterogeneousProvider;
|
||||
|
||||
// ── Cloud Claude Code: server-side sandbox execution ──
|
||||
console.log('[CloudCC] heterogeneousProvider:', JSON.stringify(heterogeneousProvider));
|
||||
if (heterogeneousProvider?.type === 'cloud-claude-code') {
|
||||
console.log('[CloudCC] ✅ Entering cloud-claude-code branch');
|
||||
console.log('[CloudCC] 1. Persisting messages to DB...');
|
||||
// Persist messages to DB (same pattern as desktop hetero)
|
||||
let cloudData: SendMessageServerResponse | undefined;
|
||||
try {
|
||||
cloudData = await aiChatService.sendMessageInServer(
|
||||
{
|
||||
agentId: operationContext.agentId,
|
||||
groupId: operationContext.groupId ?? undefined,
|
||||
newAssistantMessage: { provider: 'cloud-claude-code' },
|
||||
newTopic: !operationContext.topicId
|
||||
? {
|
||||
title: message.slice(0, 20) || t('defaultTitle', { ns: 'topic' }),
|
||||
topicMessageIds: messages.map((m) => m.id),
|
||||
}
|
||||
: undefined,
|
||||
newUserMessage: {
|
||||
content: message,
|
||||
editorData,
|
||||
files: fileIdList,
|
||||
pageSelections,
|
||||
parentId,
|
||||
},
|
||||
threadId: operationContext.threadId ?? undefined,
|
||||
topicFilter: this.#getTopicFilter(
|
||||
operationContext.agentId,
|
||||
operationContext.groupId ?? undefined,
|
||||
),
|
||||
topicId: operationContext.topicId ?? undefined,
|
||||
},
|
||||
abortController,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[CloudClaudeCode] Failed to persist messages:', e);
|
||||
this.#get().failOperation(operationId, {
|
||||
message: e instanceof Error ? e.message : 'Unknown error',
|
||||
type: 'CloudClaudeCodeError',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[CloudCC] 2. sendMessageInServer result:',
|
||||
cloudData ? 'OK' : 'EMPTY',
|
||||
'topicId:',
|
||||
cloudData?.topicId,
|
||||
'assistantMsgId:',
|
||||
cloudData?.assistantMessageId,
|
||||
);
|
||||
|
||||
if (!cloudData) return;
|
||||
|
||||
const cloudContext = {
|
||||
...operationContext,
|
||||
topicId: cloudData.topicId ?? operationContext.topicId,
|
||||
};
|
||||
|
||||
// Replace optimistic messages with persisted ones
|
||||
this.#get().replaceMessages(cloudData.messages, {
|
||||
action: 'sendMessage/cloudCC',
|
||||
context: cloudContext,
|
||||
});
|
||||
|
||||
// Handle new topic creation
|
||||
if (cloudData.isCreateNewTopic && cloudData.topicId) {
|
||||
if (cloudData.topics) {
|
||||
const pageSize = systemStatusSelectors.topicPageSize(useGlobalStore.getState());
|
||||
this.#get().internal_updateTopics(operationContext.agentId, {
|
||||
groupId: operationContext.groupId,
|
||||
items: cloudData.topics.items,
|
||||
pageSize,
|
||||
total: cloudData.topics.total,
|
||||
});
|
||||
}
|
||||
await this.#get().switchTopic(cloudData.topicId, {
|
||||
clearNewKey: true,
|
||||
skipRefreshMessage: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up temp messages
|
||||
this.#get().internal_dispatchMessage(
|
||||
{ ids: [tempId, tempAssistantId], type: 'deleteMessages' },
|
||||
{ operationId },
|
||||
);
|
||||
|
||||
this.#get().completeOperation(operationId);
|
||||
this.#get().updateOperationMetadata(operationId, { inputEditorTempState: null });
|
||||
|
||||
// Create a child operation so the stop button shows
|
||||
const { operationId: cloudOpId } = this.#get().startOperation({
|
||||
context: cloudContext,
|
||||
label: 'Cloud Claude Code',
|
||||
metadata: { heterogeneousType: 'cloud-claude-code' },
|
||||
parentOperationId: operationId,
|
||||
type: 'execHeterogeneousAgent',
|
||||
});
|
||||
|
||||
if (cloudData.assistantMessageId) {
|
||||
this.#get().associateMessageWithOperation(cloudData.assistantMessageId, cloudOpId);
|
||||
}
|
||||
|
||||
const hasCloudCCCompleted = (
|
||||
messages: Array<{ id: string; metadata?: Record<string, any> }>,
|
||||
) =>
|
||||
!!cloudData.assistantMessageId &&
|
||||
messages.some(
|
||||
(msg) =>
|
||||
msg.id === cloudData.assistantMessageId &&
|
||||
msg.metadata?.cloudClaudeCodeRunStatus === 'completed',
|
||||
);
|
||||
|
||||
console.log('[CloudCC] 3. Operation created, calling cloudClaudeCode.start...');
|
||||
// Fire-and-forget: start CC in sandbox via TRPC
|
||||
const { lambdaClient } = await import('@/libs/trpc/client');
|
||||
const topicId = cloudContext.topicId!;
|
||||
|
||||
lambdaClient.cloudClaudeCode.start
|
||||
.mutate({
|
||||
agentId,
|
||||
assistantMessageId: cloudData.assistantMessageId,
|
||||
prompt: message,
|
||||
topicId,
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
console.error('[CloudClaudeCode] start failed:', e);
|
||||
clearInterval(pollInterval);
|
||||
if (this.#get().operations?.[cloudOpId]?.status === 'running') {
|
||||
this.#get().failOperation(cloudOpId, {
|
||||
message: e instanceof Error ? e.message : 'Cloud Claude Code start failed',
|
||||
type: 'CloudClaudeCodeError',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[CloudCC] 4. start mutation fired, starting polling...');
|
||||
// Start polling for new messages from sandbox CC
|
||||
let isPolling = false;
|
||||
const pollInterval = setInterval(async () => {
|
||||
if (isPolling) return;
|
||||
isPolling = true;
|
||||
|
||||
try {
|
||||
const msgs = await messageService.getMessages(cloudContext);
|
||||
this.#get().replaceMessages(msgs, { context: cloudContext });
|
||||
|
||||
if (hasCloudCCCompleted(msgs)) {
|
||||
clearInterval(pollInterval);
|
||||
if (this.#get().operations?.[cloudOpId]?.status === 'running') {
|
||||
this.#get().completeOperation(cloudOpId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore polling errors
|
||||
} finally {
|
||||
isPolling = false;
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// Register cancel handler: stop polling + cancel sandbox
|
||||
this.#get().onOperationCancel?.(cloudOpId, async () => {
|
||||
clearInterval(pollInterval);
|
||||
// TODO: call cancel endpoint to kill sandbox CC process
|
||||
});
|
||||
|
||||
// Auto-stop polling after 10 minutes (safety net)
|
||||
setTimeout(
|
||||
() => {
|
||||
clearInterval(pollInterval);
|
||||
if (this.#get().operations?.[cloudOpId]?.status === 'running') {
|
||||
this.#get().completeOperation(cloudOpId);
|
||||
}
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
|
||||
return {
|
||||
assistantMessageId: cloudData.assistantMessageId,
|
||||
userMessageId: cloudData.userMessageId,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Desktop heterogeneous agent mode (desktop only) ──
|
||||
if (isDesktop && heterogeneousProvider) {
|
||||
// Resolve cwd up-front so the new topic is bound to a project at
|
||||
// creation time. Otherwise the row stays NULL until the post-execution
|
||||
|
||||
Reference in New Issue
Block a user