feat(agents): 新增云端智能体入口与草稿编辑

This commit is contained in:
2026-09-10 16:00:37 +08:00
parent 927a85fa22
commit 46fbae9dec
21 changed files with 921 additions and 17 deletions

View File

@@ -0,0 +1,52 @@
# Task: Implement personal cloud agent entry and draft management
## Identity
- Task ID: 20260910-personal-agent-entry-e69a9434
- Mode: Feature
- Branch: codex/20260910-personal-agent-entry-e69a9434-personal-agent-entry
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260910-personal-agent-entry-e69a9434
- Base commit: 927a85fa22e003b2e09740f41e6a91c71ee278c1
- Owner: codex
- Status: Ready for Integration
## Scope
- Implement the first personal cloud Agent slice: Main-owned cloud Agent access, module navigation and draft creation/editor.
- Sharing, published invocation, model billing and scheduled execution remain subsequent slices of the accepted first release.
## Intent And Constraints
- Concurrent Task Gate and Planning Gate passed; bundled ownership matches this task, mode, branch, worktree and base. All writes remain in this isolated checkout.
- Read required memory entry, active record, relevant integrated architecture/auth decisions, existing three-end proposals and peer scope/intent/promotion sections. Undefined peers remain unknown; no concrete semantic dependency on this slice was found. Existing user/module-access seams remain authoritative.
- User-approved direction: personal creators without organizations, WS stable account identity and Token Point ledger, Yuxi Agent ownership, Main-only cloud credentials. Xiaozhi is excluded. One fresh independent reviewer and local commits are explicitly approved in the current follow-up. No additional subagents, primary-runtime changes, push or deployment.
- Credential policy for this draft-only slice: access token lasts at most five minutes and no longer than the originating WS access token. Main discards cached credentials and late responses at account change/logout. WS checks current account/module eligibility on each exchange; already-issued credentials have that bounded remaining lifetime. Local Yuxi deletion/lock is checked on every call. No perpetual refresh credential or runtime access is issued.
- Plan: implement typed session exchange and test identity/error projection; implement managed identity/private drafts with idempotent creation and conditional save, verified over PostgreSQL/HTTP; implement Main/Renderer entry and verify account-switch/conflict/navigation behavior, typecheck and build.
- Source-doc changes are scoped to current behavior; canonical project-memory promotion is deferred to integration.
## Outcome
- Added the independent Agents module, My Agents list, create form and private draft editor. Main exclusively exchanges/caches cloud credentials and projects safe typed DTOs through Host API. It binds calls to the current WS account epoch, rejects late responses, bounds request/body-read time and preserves explicit create operation IDs. Renderer supports conflict readback with local input preserved, explicit resolution, draft/creation leave warnings, account reset and list refresh. Switched the existing hash router to the data-router API so navigation blocking covers module changes/history; existing module navigation was tested.
- Independent review approved the complete three-repository increment after the creation-card navigation fix and Yuxi configuration-template correction. There are no unresolved actionable findings. The reviewed result and verification are ready for integration; no merge, push, release or deployment was performed, and this isolated worktree is retained.
## Verification
- Final related Vitest set: 94 passed across cloud Agents Main/UI, module access, auth routes/store, Sidebar and MainLayout tests.
- Electron Playwright: 10 passed across cloud-agents.spec.ts, main-navigation.spec.ts and app-smoke.spec.ts; includes create/edit/save/reopen, leave protection, four-entry resizing, existing Code navigation, login/setup and signed-out route guards. The cloud UI test mocks Host API, while actual Yuxi HTTP/PG is verified in the Yuxi task; it is not a three-server production integration claim.
- `pnpm typecheck` (Renderer), targeted ESLint and `pnpm build:vite` (Renderer/Main/preload/utility) passed. The final Electron run used this build.
- Additional full Main `tsc -p tsconfig.node.json --noEmit --composite false` remains failed with 61 diagnostics in unchanged files; git diff against the recorded base confirms those diagnostic files have no task changes. The new cloud-agents diagnostic was corrected. The standard composite config also has existing missing shared-file include diagnostics. Do not report all-project/Main typecheck as passing.
- Inspected the actual Electron screenshot at test-results/cloud-agents-personal-clou-f6c7b-eave-protection-in-Electron/cloud-agent-editor.png; no clipping in the 1280x800 editor view. git diff --check passed. No installer release, push or production deployment.
## Follow-ups
- Retain the three reviewed worktrees for integration. Next implementation slice is capability selection/preview chat with creator billing, then publication/sharing/API and automatic tasks; these remain first-release scope, not delivered by this draft-only slice. Existing full-Main type diagnostics remain with their owning tasks.
## Promotion Candidates
- Target: .project-docs/current-state.md and the owning MakeLore module/auth architecture record. Proposal: a fourth cloud Agents module, credentials/network in Main, Renderer through Host API, personal draft state with create replay and revision-based explicit saves. Evidence: 94 Vitest cases, 10 Electron cases, build and inspected screenshot. Future impact: add chat/automation/config/publish workspaces on the verified Yuxi foundations; keep creator/caller/payer separate. Semantic conflict: no new organization UI or local Code runtime ownership; earlier planning should distinguish existing Yuxi capability from new product adaptation. Human confirmation: direction already accepted; the approved independent review is complete.
## Review And Commit Follow-up
- User approved one independent reviewer without inherited development context and local commits after material findings are resolved. Same-task ownership and planning gates passed on resume; other task records have no scope changes. Review is a read-only subtask of these existing owned tasks.
- Fresh reviewer /root/independent_review (review task 01a08a42-9a55-79f3-807f-016e84308290) read the full requirements, complete staged/working diffs against each recorded base, relevant source/standards and verification limitations, then rechecked both fixes and returned Approve with no unresolved actionable findings. One reviewer was used; no inherited development context was provided. Local commits are authorized; remote operations remain outside this handoff.
- A new regression demonstrated that selecting an existing card during creation bypassed route blocking and discarded input or the uncertain creation operation. Cards are now disabled while the creation form is open; explicit cancel/list confirmation restores selection. The regression failed on the prior implementation and passes after this two-line UI fix. The five affected cloud Agents/auth/module Vitest files pass 83 tests; targeted ESLint and the final Renderer/Main/preload/utility build pass.

View File

@@ -2,9 +2,10 @@
**一念成光,万物可创。**
Makelore 是一个面向软件、视觉创作智能机器人的 AI 桌面工作台。当前版本为 `2.0.0`包含三个已开通产品模块。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页:
Makelore 是一个面向软件、视觉创作智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`源码提供四个模块入口;云智能体需配套配置 WS/Yuxi 服务。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页:
- `Makelore CodeAI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。
- `Makelore AgentsAI 智能体`使用现有账号创建私人云草稿编辑名称、用途和角色指令。Main 持有 WS/Yuxi 云凭据Renderer 只通过 Host API 获取草稿保存检查修订号冲突保留本地输入离开时提示未保存修改。当前没有试聊、发布、分享、API 调用和自动任务;保存不扣词元点数。
- `Makelore CanvasAI 绘画`每个设计项目Workspace维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
- `Makelore RobotAI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。

View File

@@ -4,6 +4,7 @@ import { handleAiProxyRoutes } from './routes/ai-proxy';
import { handleAiHardwareRoutes } from './routes/ai-hardware';
import { handleAppRoutes } from './routes/app';
import { handleAuthRoutes } from './routes/auth';
import { handleCloudAgentsRoutes } from './routes/cloud-agents';
import { handleImageWorkspaceRoutes } from './routes/image-workspace';
import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum';
import { handleDataServiceRoutes } from './routes/data-service';
@@ -41,6 +42,7 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [
handleAiHardwareRoutes,
handleAppRoutes,
handleAuthRoutes,
handleCloudAgentsRoutes,
handleImageWorkspaceRoutes,
handleImagePromptMuseumRoutes,
handleDataServiceRoutes,

View File

@@ -0,0 +1,34 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { CLOUD_AGENTS_PATH } from '../../../shared/cloud-agents';
import { CloudAgentsError, CloudAgentsModule } from '../../services/cloud-agents';
import { parseJsonBody, sendJson } from '../route-utils';
const cloudAgents = new CloudAgentsModule();
export async function handleCloudAgentsRoutes(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
if (!url.pathname.startsWith(CLOUD_AGENTS_PATH + '/')) return false;
res.setHeader('Cache-Control', 'no-store');
const path = url.pathname.slice(CLOUD_AGENTS_PATH.length);
try {
let data: unknown;
if ((path === '/bootstrap' || path === '/agents') && req.method === 'GET') {
data = await cloudAgents.list(url.searchParams.get('cursor'));
} else if (path === '/agents' && req.method === 'POST') {
data = await cloudAgents.create(await parseJsonBody(req));
} else {
const match = /^\/agents\/(ml-[a-f0-9]{32})(\/draft)?$/.exec(path);
if (match && !match[2] && req.method === 'GET') data = await cloudAgents.get(match[1]);
else if (match && match[2] && req.method === 'PATCH') data = await cloudAgents.save(match[1], await parseJsonBody(req));
else {
sendJson(res, 404, { error: '智能体接口不存在' });
return true;
}
}
sendJson(res, 200, data);
} catch (error) {
const failure = error instanceof CloudAgentsError ? error
: new CloudAgentsError(error instanceof SyntaxError ? 400 : 502, error instanceof SyntaxError ? 'invalid_input' : 'cloud_service_unavailable');
sendJson(res, failure.status, { success: false, error: failure.message, code: failure.code });
}
return true;
}

View File

@@ -0,0 +1,199 @@
import type { CloudAgentDraft, CloudAgentPage, CreateCloudAgent, SaveCloudAgentDraft } from '../../shared/cloud-agents';
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
import { proxyAwareFetch, runWithDeadline } from '../utils/proxy-fetch';
import {
getValidWorksSquareAccessToken,
getWorksSquareAccountBinding,
isCurrentWorksSquareAccountBinding,
subscribeWorksSquareSession,
type WorksSquareAccountBinding,
} from './works-square-session';
const MESSAGES: Record<string, string> = {
session_expired: '登录已过期,请重新登录',
account_changed: '账号已切换,请重新打开智能体',
account_locked: '账号已锁定,请稍后重试',
module_unavailable: 'AI 智能体模块暂不可用',
agent_not_found: '智能体不存在',
draft_revision_conflict: '草稿已在其他设备更新,你的输入已保留',
operation_conflict: '创建操作的内容发生冲突,请刷新列表确认',
cloud_service_unavailable: '智能体服务暂时不可用,请重试',
invalid_input: '请检查名称、用途和配置内容',
};
export class CloudAgentsError extends Error {
constructor(readonly status: number, readonly code: string) {
super(MESSAGES[code] ?? MESSAGES.cloud_service_unavailable);
}
}
function record(value: unknown): Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown> : {};
}
function textField(value: unknown, max: number, nonempty = true): string {
if (typeof value !== 'string' || value.length > max || (nonempty && !value.trim())) {
throw new CloudAgentsError(422, 'invalid_input');
}
return value;
}
function revision(value: unknown): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
throw new CloudAgentsError(422, 'invalid_input');
}
return value;
}
function slug(value: string): string {
if (!/^ml-[a-f0-9]{32}$/.test(value)) throw new CloudAgentsError(422, 'invalid_input');
return value;
}
function projectDraft(value: unknown): CloudAgentDraft {
const item = record(value);
try { return {
slug: slug(textField(item.slug, 80)),
name: textField(item.name, 100),
purpose: textField(item.purpose, 2000),
system_prompt: textField(item.system_prompt, 32000, false),
draft_revision: revision(item.draft_revision),
updated_at: textField(item.updated_at, 40),
}; } catch { throw new CloudAgentsError(502, 'cloud_service_unavailable'); }
}
type Session = {
binding: WorksSquareAccountBinding;
accessToken: string;
apiBaseUrl: string;
expiresAt: number;
};
/** Main owns both clouds' credentials; public methods return only Agent drafts. */
export class CloudAgentsModule {
private cached: Session | null = null;
private readonly unsubscribe: () => void;
constructor(private readonly fetchImpl: (input: string, init?: RequestInit) => Promise<Response> = proxyAwareFetch) {
this.unsubscribe = subscribeWorksSquareSession(() => {
if (this.cached && !isCurrentWorksSquareAccountBinding(this.cached.binding)) this.cached = null;
});
}
dispose(): void {
this.cached = null;
this.unsubscribe();
}
async list(cursor: string | null = null): Promise<CloudAgentPage> {
if (cursor !== null && !/^\d{1,16}$/.test(cursor)) throw new CloudAgentsError(422, 'invalid_input');
const body = record(await this.request('/api/makelore/agents' + (cursor ? `?cursor=${cursor}` : ''), 'GET'));
if (!Array.isArray(body.agents) || body.agents.length > 100
|| (body.next_cursor !== null && (typeof body.next_cursor !== 'string' || !/^\d{1,16}$/.test(body.next_cursor)))) {
throw new CloudAgentsError(502, 'cloud_service_unavailable');
}
return { agents: body.agents.map(projectDraft), next_cursor: body.next_cursor as string | null };
}
async get(agentSlug: string): Promise<CloudAgentDraft> {
return projectDraft(await this.request(`/api/makelore/agents/${slug(agentSlug)}`, 'GET'));
}
async create(value: unknown): Promise<CloudAgentDraft> {
const input = record(value);
const operationId = textField(input.operation_id, 36);
if (!/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(operationId)) {
throw new CloudAgentsError(422, 'invalid_input');
}
const body: CreateCloudAgent = {
operation_id: operationId,
name: textField(input.name, 100).trim(),
purpose: textField(input.purpose, 2000).trim(),
};
return projectDraft(await this.request('/api/makelore/agents', 'POST', body));
}
async save(agentSlug: string, value: unknown): Promise<CloudAgentDraft> {
const input = record(value);
const body: SaveCloudAgentDraft = {
expected_revision: revision(input.expected_revision),
name: textField(input.name, 100).trim(),
purpose: textField(input.purpose, 2000).trim(),
system_prompt: textField(input.system_prompt, 32000, false),
};
return projectDraft(await this.request(`/api/makelore/agents/${slug(agentSlug)}/draft`, 'PATCH', body));
}
private async request(path: string, method: string, body?: unknown): Promise<unknown> {
const binding = getWorksSquareAccountBinding();
if (!binding) throw new CloudAgentsError(401, 'session_expired');
try {
return await runWithDeadline(async (signal) => {
const session = await this.session(binding, signal);
this.requireCurrent(binding);
const result = await this.fetchJson(session.apiBaseUrl + path, {
method, signal, redirect: 'error',
headers: { Authorization: `Bearer ${session.accessToken}`, 'Content-Type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
this.requireCurrent(binding);
return result;
}, 30000);
} catch (error) {
this.requireCurrent(binding);
if (error instanceof CloudAgentsError) {
if (error.status === 401) this.cached = null;
if (error.status === 422) throw new CloudAgentsError(502, 'cloud_service_unavailable');
throw error;
}
throw new CloudAgentsError(502, 'cloud_service_unavailable');
}
}
private requireCurrent(binding: WorksSquareAccountBinding): void {
if (!isCurrentWorksSquareAccountBinding(binding)) throw new CloudAgentsError(409, 'account_changed');
}
private async session(binding: WorksSquareAccountBinding, signal: AbortSignal): Promise<Session> {
if (this.cached && this.cached.expiresAt > Date.now() + 5000
&& this.cached.binding.accountKey === binding.accountKey && this.cached.binding.epoch === binding.epoch) {
return this.cached;
}
const token = await getValidWorksSquareAccessToken();
this.requireCurrent(binding);
if (!token) throw new CloudAgentsError(401, 'session_expired');
const value = record(await this.fetchJson(`${WORKS_SQUARE_CONFIG.apiBaseUrl}/api/cloud-agents/session`, {
method: 'POST', signal, redirect: 'error',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
}));
this.requireCurrent(binding);
const url = new URL(textField(value.api_base_url, 2048));
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)))
|| url.username || url.password || url.search || url.hash
|| value.scope !== 'makelore-agent-drafts' || value.token_type !== 'bearer'
|| typeof value.expires_at !== 'number' || value.expires_at * 1000 <= Date.now()
|| value.expires_at * 1000 > Date.now() + 300000) {
throw new CloudAgentsError(502, 'cloud_service_unavailable');
}
this.cached = {
binding, accessToken: textField(value.access_token, 8192),
apiBaseUrl: url.toString().replace(/\/+$/, ''), expiresAt: value.expires_at * 1000,
};
return this.cached;
}
private async fetchJson(url: string, init: RequestInit): Promise<unknown> {
const response = await this.fetchImpl(url, init);
const raw = await response.text();
if (raw.length > 4_000_000) throw new CloudAgentsError(502, 'cloud_service_unavailable');
const body: unknown = JSON.parse(raw);
if (!response.ok) {
const code = record(record(body).detail).code;
throw new CloudAgentsError(
response.status, typeof code === 'string' && Object.hasOwn(MESSAGES, code) ? code : 'cloud_service_unavailable',
);
}
return body;
}
}

28
shared/cloud-agents.ts Normal file
View File

@@ -0,0 +1,28 @@
export const CLOUD_AGENTS_PATH = '/api/cloud-agents';
export interface CloudAgentDraft {
slug: string;
name: string;
purpose: string;
system_prompt: string;
draft_revision: number;
updated_at: string;
}
export interface CloudAgentPage {
agents: CloudAgentDraft[];
next_cursor: string | null;
}
export interface CreateCloudAgent {
operation_id: string;
name: string;
purpose: string;
}
export interface SaveCloudAgentDraft {
expected_revision: number;
name: string;
purpose: string;
system_prompt: string;
}

View File

@@ -2,6 +2,7 @@ export const MODULE_ACCESS_KEYS = [
'programming',
'design',
'robot',
'cloud_agents',
] as const;
export type ModuleAccessKey = typeof MODULE_ACCESS_KEYS[number];
@@ -12,6 +13,7 @@ export const DEFAULT_MODULE_ACCESS: ModuleAccess = {
programming: true,
design: true,
robot: true,
cloud_agents: true,
};
export function normalizeModuleAccess(value: unknown): ModuleAccess {
@@ -23,5 +25,6 @@ export function normalizeModuleAccess(value: unknown): ModuleAccess {
programming: typeof record.programming === 'boolean' ? record.programming : true,
design: typeof record.design === 'boolean' ? record.design : true,
robot: typeof record.robot === 'boolean' ? record.robot : true,
cloud_agents: typeof record.cloud_agents === 'boolean' ? record.cloud_agents : true,
};
}

View File

@@ -40,6 +40,7 @@ const ProjectConfiguration = lazy(() => import('./pages/ProjectConfiguration').t
const Workbench = lazy(() => import('./pages/Workbench').then(({ Workbench: component }) => ({ default: component })));
const ImageCanvas = lazy(() => import('./pages/ImageCanvas').then(({ ImageCanvas: component }) => ({ default: component })));
const AiHardware = lazy(() => import('./pages/AiHardware').then(({ AiHardware: component }) => ({ default: component })));
const CloudAgents = lazy(() => import('./pages/CloudAgents'));
const Settings = lazy(() => import('./pages/Settings').then(({ Settings: component }) => ({ default: component })));
const Setup = lazy(() => import('./pages/Setup').then(({ Setup: component }) => ({ default: component })));
const Login = lazy(() => import('./pages/Login').then(({ Login: component }) => ({ default: component })));
@@ -457,6 +458,7 @@ function App() {
<Route path="/image-canvas" element={<ImageCanvas />} />
<Route path="/image-prompts/*" element={<Navigate to="/image-canvas" replace />} />
<Route path="/ai-hardware" element={<AiHardware />} />
<Route path="/cloud-agents/*" element={<CloudAgents />} />
<Route path="/workbench/:projectId" element={<Workbench />} />
<Route path="/models" element={<Models />} />
<Route path="/settings/*" element={<Settings />} />

View File

@@ -781,7 +781,13 @@ export function Sidebar({
</div>
</>
) : (
<div data-testid="sidebar-robot-navigation" className="px-2 py-3">
activeModule === 'cloud_agents' ? <div data-testid="sidebar-cloud-agents-navigation" className="px-2 py-3">
<button type="button" aria-label="我的智能体" onClick={() => navigate('/cloud-agents')}
className={cn('flex min-h-10 w-full items-center gap-2 rounded-lg bg-brand-soft px-2 py-2 text-left text-sm font-semibold', sidebarCollapsed && 'justify-center px-0')}>
<Bot className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed && <span></span>}
</button>
</div> : <div data-testid="sidebar-robot-navigation" className="px-2 py-3">
<button
type="button"
aria-label="机器人工作台"

View File

@@ -1,10 +1,10 @@
import type { LucideIcon } from 'lucide-react';
import { Bot, Code2, Paintbrush } from 'lucide-react';
import { Bot, Code2, Paintbrush, Sparkles } from 'lucide-react';
import type { ModuleAccess, ModuleAccessKey } from '../../shared/module-access';
export const AI_MODULE_SELECTION_PATH = '/module-select';
export type AiModuleId = 'programming' | 'painting' | 'robot';
export type AiModuleId = 'programming' | 'painting' | 'robot' | 'cloud_agents';
export type AiModuleDefinition = {
id: AiModuleId;
@@ -46,12 +46,23 @@ export const aiModules: readonly AiModuleDefinition[] = [
Icon: Bot,
switcherLabel: 'Robot 机器',
},
{
id: 'cloud_agents',
title: 'Makelore Agents',
subtitle: 'AI 智能体',
description: '配置专属智能体,从定义用途与角色开始',
route: '/cloud-agents',
enabled: true,
Icon: Sparkles,
switcherLabel: 'Agents 智能体',
},
];
const moduleAccessKeyById: Record<AiModuleId, ModuleAccessKey> = {
programming: 'programming',
painting: 'design',
robot: 'robot',
cloud_agents: 'cloud_agents',
};
const PROGRAMMING_ROUTE_PREFIXES = [
@@ -95,6 +106,7 @@ export function isProgrammingProviderRoute(pathname: string): boolean {
}
export function getGuardedAiModuleForPath(pathname: string): AiModuleId | null {
if (matchesRoute(pathname, '/cloud-agents')) return 'cloud_agents';
if (pathname === '/image-canvas'
|| pathname.startsWith('/image-canvas/')
|| pathname === '/image-prompts'

View File

@@ -0,0 +1,16 @@
import { hostApiFetch } from './host-api';
import { CLOUD_AGENTS_PATH, type CloudAgentDraft, type CloudAgentPage, type CreateCloudAgent, type SaveCloudAgentDraft } from '../../shared/cloud-agents';
export const cloudAgentsApi = {
list: (cursor: string | null = null) => hostApiFetch<CloudAgentPage>(
CLOUD_AGENTS_PATH + '/agents' + (cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''),
),
get: (slug: string) => hostApiFetch<CloudAgentDraft>(`${CLOUD_AGENTS_PATH}/agents/${encodeURIComponent(slug)}`),
create: (input: CreateCloudAgent) => hostApiFetch<CloudAgentDraft>(CLOUD_AGENTS_PATH + '/agents', {
method: 'POST', body: JSON.stringify(input),
}),
save: (slug: string, input: SaveCloudAgentDraft) => hostApiFetch<CloudAgentDraft>(
`${CLOUD_AGENTS_PATH}/agents/${encodeURIComponent(slug)}/draft`,
{ method: 'PATCH', body: JSON.stringify(input) },
),
};

View File

@@ -3,7 +3,7 @@
*/
import React from 'react';
import ReactDOM from 'react-dom/client';
import { HashRouter } from 'react-router-dom';
import { createHashRouter, RouterProvider } from 'react-router-dom';
import App from './App';
import './i18n';
import './styles/globals.css';
@@ -17,10 +17,10 @@ if (platform) {
initializeDefaultTransports();
const router = createHashRouter([{ path: '*', element: <App /> }]);
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<HashRouter>
<App />
</HashRouter>
<RouterProvider router={router} />
</React.StrictMode>,
);

View File

@@ -0,0 +1,247 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBlocker, useLocation } from 'react-router-dom';
import { ArrowLeft, Check, Loader2, Plus, Save, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { useAuthStore } from '@/stores/auth';
import type { CloudAgentDraft, CreateCloudAgent } from '../../../shared/cloud-agents';
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : '暂时无法连接智能体服务,请重试';
}
function AgentWorkspace() {
const [agents, setAgents] = useState<CloudAgentDraft[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selected, setSelected] = useState<CloudAgentDraft | null>(null);
const [creating, setCreating] = useState(false);
const mounted = useRef(true);
const request = useRef(0);
const load = useCallback(async (next: string | null = null) => {
const generation = ++request.current;
setLoading(true);
setError('');
try {
const page = await cloudAgentsApi.list(next);
if (!mounted.current || generation !== request.current) return;
setAgents((previous) => next ? [...previous, ...page.agents] : page.agents);
setCursor(page.next_cursor);
} catch (failure) {
if (mounted.current && generation === request.current) setError(errorMessage(failure));
} finally {
if (mounted.current && generation === request.current) setLoading(false);
}
}, []);
useEffect(() => {
mounted.current = true;
void load();
return () => { mounted.current = false; request.current += 1; };
}, [load]);
const accepted = (agent: CloudAgentDraft) => {
if (!mounted.current) return;
setAgents((previous) => [agent, ...previous.filter((item) => item.slug !== agent.slug)]);
setSelected(agent);
setCreating(false);
};
if (selected) return <DraftEditor key={selected.slug} initial={selected} onBack={() => {
setSelected(null);
void load();
}} onSaved={accepted} />;
return (
<section className="mx-auto max-w-5xl pb-10" data-testid="cloud-agents-page">
<header className="mb-8 flex flex-wrap items-end justify-between gap-4 border-b border-border pb-6">
<div>
<p className="mb-2 text-sm text-muted-foreground">Makelore Agents</p>
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
<Button onClick={() => setCreating(true)} disabled={creating}><Plus className="mr-2 h-4 w-4" /></Button>
</header>
{creating && <CreateAgentForm onCreated={accepted} onCancel={() => { setCreating(false); void load(); }} />}
{error && <div role="alert" className="mb-5 rounded-lg border border-border p-4 text-sm">
<p>{error}</p><Button variant="outline" className="mt-3" onClick={() => void load()}></Button>
</div>}
{loading && agents.length === 0 ? <p role="status" className="py-12 text-muted-foreground"></p>
: !error && agents.length === 0 && !creating ? <div className="rounded-xl border border-dashed border-border px-6 py-16 text-center">
<Sparkles className="mx-auto mb-5 h-9 w-9 text-violet-500" strokeWidth={1.4} />
<h2 className="text-lg font-medium"></h2>
<p className="mb-6 mt-2 text-sm text-muted-foreground"></p>
<Button variant="outline" onClick={() => setCreating(true)}></Button>
</div> : null}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{agents.map((agent) => <button key={agent.slug} disabled={creating} onClick={() => setSelected(agent)}
className="rounded-xl border border-border bg-background p-5 text-left transition-colors enabled:hover:bg-violet-50/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50">
<span className="mb-4 flex items-center justify-between"><Sparkles className="h-5 w-5 text-violet-500" />
<span className="text-xs text-muted-foreground">稿</span></span>
<h2 className="truncate font-medium">{agent.name}</h2>
<p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{agent.purpose}</p>
<p className="mt-5 text-xs text-muted-foreground"> {agent.draft_revision} · </p>
</button>)}
</div>
{cursor && <Button className="mt-5" variant="outline" disabled={loading} onClick={() => void load(cursor)}></Button>}
</section>
);
}
function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgentDraft) => void; onCancel: () => void }) {
const [name, setName] = useState('');
const [purpose, setPurpose] = useState('');
const [intent, setIntent] = useState<CreateCloudAgent | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const alive = useRef(true);
const blocker = useBlocker(busy || intent !== null || Boolean(name || purpose));
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => {
if (busy || intent || name || purpose) { event.preventDefault(); event.returnValue = ''; }
};
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [busy, intent, name, purpose]);
const submit = async () => {
if (busy || !name.trim() || !purpose.trim()) return;
const input = intent ?? { operation_id: crypto.randomUUID(), name: name.trim(), purpose: purpose.trim() };
setIntent(input);
setBusy(true);
setError('');
try {
const agent = await cloudAgentsApi.create(input);
if (alive.current) onCreated(agent);
} catch (failure) {
if (alive.current) setError(errorMessage(failure));
} finally {
if (alive.current) setBusy(false);
}
};
return <form className="mb-8 space-y-4 rounded-xl border border-border p-5" onSubmit={(event) => { event.preventDefault(); void submit(); }}>
<h2 className="font-medium"></h2>
<label className="block space-y-2 text-sm"><span></span><Input autoFocus maxLength={100} value={name}
disabled={busy || intent !== null} onChange={(event) => setName(event.target.value)} placeholder="例如:我的写作搭档" /></label>
<label className="block space-y-2 text-sm"><span></span><Textarea maxLength={2000} value={purpose}
disabled={busy || intent !== null} onChange={(event) => setPurpose(event.target.value)} placeholder="希望它帮你完成什么?" /></label>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="flex gap-3">
<Button type="submit" disabled={busy || !name.trim() || !purpose.trim()}>{busy ? '正在创建…' : intent ? '重试创建' : '创建草稿'}</Button>
<Button type="button" variant="ghost" disabled={busy} onClick={onCancel}>{intent ? '返回列表确认' : '取消'}</Button>
</div>
{blocker.state === 'blocked' && <div role="dialog" aria-modal="true" aria-label="离开创建" className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-6">
<div className="max-w-md space-y-4 rounded-xl bg-background p-6 shadow-lg">
<h2 className="font-medium">{intent ? '本次创建结果尚未确认' : '还有未提交的内容'}</h2>
<p className="text-sm text-muted-foreground">{intent ? '可继续重试确认结果。如果离开,请先查看智能体列表再创建。' : '离开将放弃本次输入。'}</p>
<div className="flex gap-2">
<Button type="button" onClick={() => blocker.reset()}></Button>
<Button type="button" variant="outline" disabled={busy} onClick={() => blocker.proceed()}></Button>
</div>
</div>
</div>}
</form>;
}
function DraftEditor({ initial, onBack, onSaved }: {
initial: CloudAgentDraft; onBack: () => void; onSaved: (agent: CloudAgentDraft) => void;
}) {
const [saved, setSaved] = useState(initial);
const [draft, setDraft] = useState(initial);
const [remote, setRemote] = useState<CloudAgentDraft | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [leaving, setLeaving] = useState(false);
const alive = useRef(true);
const dirty = draft.name !== saved.name || draft.purpose !== saved.purpose || draft.system_prompt !== saved.system_prompt;
const blocker = useBlocker(dirty || busy);
const finishLeaving = () => {
if (blocker.state === 'blocked') blocker.proceed();
else onBack();
};
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => { if (dirty) { event.preventDefault(); event.returnValue = ''; } };
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [dirty]);
const save = async (exit = false) => {
if (busy) return;
setBusy(true);
setError('');
try {
const result = await cloudAgentsApi.save(saved.slug, {
expected_revision: saved.draft_revision, name: draft.name, purpose: draft.purpose, system_prompt: draft.system_prompt,
});
if (!alive.current) return;
setSaved(result); setDraft(result); setRemote(null); onSaved(result);
if (exit) finishLeaving();
} catch (failure) {
if (!alive.current) return;
setError(errorMessage(failure));
// A readback also resolves an uncertain save without silently replacing local edits.
try {
const latest = await cloudAgentsApi.get(saved.slug);
if (alive.current && latest.draft_revision !== saved.draft_revision) setRemote(latest);
} catch { /* Keep the original save failure and the user's input. */ }
} finally { if (alive.current) setBusy(false); }
};
return <section className="mx-auto max-w-4xl pb-10" data-testid="cloud-agent-editor">
<Button variant="ghost" className="mb-5 -ml-3" disabled={busy} onClick={() => dirty ? setLeaving(true) : onBack()}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<header className="mb-6 flex items-center justify-between gap-4 border-b border-border pb-5">
<div><p className="mb-1 text-xs text-muted-foreground">稿 · {saved.draft_revision}</p>
<h1 className="text-xl font-semibold">{saved.name}</h1></div>
<Button disabled={busy || !dirty || !draft.name.trim() || !draft.purpose.trim() || remote !== null} onClick={() => void save()}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : dirty ? <Save className="mr-2 h-4 w-4" /> : <Check className="mr-2 h-4 w-4" />}
{busy ? '保存中…' : dirty ? '保存草稿' : '已保存'}
</Button>
</header>
{error && <p role="alert" className="mb-4 text-sm text-destructive">{error}</p>}
{remote && <div className="mb-5 space-y-3 rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm">
<p className="font-medium"> {remote.draft_revision}</p>
<details><summary className="cursor-pointer">稿</summary><p className="mt-2">{remote.name} · {remote.purpose}</p>
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap font-sans">{remote.system_prompt}</pre></details>
<div className="flex flex-wrap gap-2"><Button variant="outline" onClick={() => { setSaved(remote); setDraft(remote); setRemote(null); setError(''); }}>使稿</Button>
<Button variant="outline" onClick={() => { setSaved(remote); setRemote(null); setError(''); }}></Button></div>
</div>}
<div className="grid gap-8 md:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-5">
<label className="block space-y-2 text-sm"><span></span><Input maxLength={100} value={draft.name} disabled={busy}
onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></label>
<label className="block space-y-2 text-sm"><span></span><Textarea maxLength={2000} value={draft.purpose} disabled={busy}
onChange={(event) => setDraft({ ...draft, purpose: event.target.value })} /></label>
<label className="block space-y-2 text-sm"><span></span><Textarea className="min-h-64 leading-7" maxLength={32000}
value={draft.system_prompt} disabled={busy} onChange={(event) => setDraft({ ...draft, system_prompt: event.target.value })}
placeholder="描述它的工作方式、回答风格,以及需要遵守的要求。" /></label>
</div>
<aside className="space-y-3 text-sm leading-6 text-muted-foreground">
<h2 className="font-medium text-foreground"></h2>
<p></p>
<p>稿</p>
</aside>
</div>
{(leaving || blocker.state === 'blocked') && <div role="dialog" aria-modal="true" aria-label="未保存的修改" className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-6">
<div className="max-w-md rounded-xl bg-background p-6 shadow-lg"><h2 className="font-medium"></h2>
<p className="my-4 text-sm text-muted-foreground"></p>
<div className="flex flex-wrap gap-2"><Button disabled={busy || remote !== null || !draft.name.trim() || !draft.purpose.trim()} onClick={() => void save(true)}></Button>
<Button variant="outline" disabled={busy} onClick={finishLeaving}></Button>
<Button variant="ghost" onClick={() => { setLeaving(false); if (blocker.state === 'blocked') blocker.reset(); }}></Button></div></div>
</div>}
</section>;
}
export default function CloudAgents() {
const accountId = useAuthStore((state) => state.user?.userId);
const location = useLocation();
return <AgentWorkspace key={`${accountId ?? 'signed-out'}:${location.key}`} />;
}

View File

@@ -12,7 +12,8 @@ import { cn } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth';
import { getUserProfileDisplayName } from '@/stores/user-profile';
const moduleSelectionContent: Record<AiModuleId, { label: string; image: string; imageAlt: string }> = {
const moduleSelectionContent: Record<AiModuleId, { label: string; image?: string; imageAlt: string }> = {
cloud_agents: { label: 'Agents 智能体', imageAlt: '个人云智能体' },
programming: { label: 'Code 编程', image: moduleGameImage, imageAlt: '游戏创作工作台' },
painting: { label: 'Canvas 设计', image: moduleCanvasImage, imageAlt: '数位板设计创作' },
robot: { label: 'Robot 机器', image: moduleRobotImage, imageAlt: '青少年管理机器人硬件与设备绑定' },
@@ -89,12 +90,14 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea
<span className="module-option-card-frame" aria-hidden="true" />
<div className="module-option-card-surface relative flex h-full w-full rounded-lg bg-background">
<div className="module-option-card-image relative shrink-0">
<img
{content.image ? <img
src={content.image}
alt={content.imageAlt}
className={cn('module-option-card-image-media h-full w-full object-cover', !enabled && 'grayscale')}
draggable="false"
/>
/> : <div className="flex h-full w-full items-center justify-center bg-violet-50 text-violet-600">
<module.Icon className="h-12 w-12" strokeWidth={1.25} />
</div>}
</div>
<div className="module-option-card-content relative min-h-0 flex-1">
<p className="module-option-card-title font-medium tracking-[-0.02em]">{content.label}</p>

View File

@@ -0,0 +1,65 @@
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
test('personal cloud Agent creation, draft save and leave protection in Electron', async ({ launchElectronApp }, testInfo) => {
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
const applicationUrl = page.url();
await page.goto('about:blank');
// This UI test owns the Host API boundary; real cloud HTTP/PG is tested separately.
await app.evaluate(({ ipcMain }) => {
let draft: Record<string, unknown> | null = null;
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => {
const path = request.path ?? '';
const method = request.method ?? 'GET';
if (path === '/api/auth/session/sync') return result({
success: true, session: { accessToken: 'ui-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false },
});
if (path === '/api/auth/me') return result({
success: true, user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] },
moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true },
});
if (path === '/api/works/user/agent-profile') return result({
success: true, profile: { display_name: '创作者', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-09-10T00:00:00Z' },
});
if (path.startsWith('/api/cloud-agents/')) {
const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {};
if (method === 'POST') draft = {
slug: 'ml-' + 'a'.repeat(32), name: body.name, purpose: body.purpose, system_prompt: body.purpose,
draft_revision: 1, updated_at: '2026-09-10T00:00:00Z',
};
if (method === 'PATCH') draft = { ...draft, name: body.name, purpose: body.purpose, system_prompt: body.system_prompt, draft_revision: 2 };
if (method === 'GET' && (path.endsWith('/agents') || path.endsWith('/bootstrap'))) return result({ agents: draft ? [draft] : [], next_cursor: null });
return result(draft);
}
return result({ success: true });
});
});
await page.goto(applicationUrl);
await page.getByTestId('ai-module-option-cloud_agents').click();
await expect(page.getByTestId('sidebar-cloud-agents-navigation')).toBeVisible();
await expect(page.getByTestId('sidebar-robot-navigation')).toHaveCount(0);
await page.getByRole('button', { name: '创建第一个智能体' }).click();
await page.getByLabel('名称', { exact: true }).fill('我的写作搭档');
await page.getByLabel('用途', { exact: true }).fill('整理灵感,把想法写成清晰的文章');
await page.getByRole('button', { name: '创建草稿', exact: true }).click();
await expect(page.getByTestId('cloud-agent-editor')).toBeVisible();
await page.getByLabel('角色与指令').fill('使用简洁自然的中文。先理解我的目的,再协助梳理结构。');
await page.getByTestId('sidebar-module-switcher-trigger').click();
await expect(page.getByRole('dialog', { name: '未保存的修改' })).toBeVisible();
await page.getByRole('button', { name: '继续编辑', exact: true }).click();
await page.getByRole('button', { name: '保存草稿', exact: true }).click();
await expect(page.getByRole('button', { name: '已保存', exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('cloud-agent-editor.png') });
await page.getByTestId('sidebar-module-switcher-trigger').click();
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
await page.getByTestId('ai-module-option-cloud_agents').click();
await page.getByRole('button', { name: /我的写作搭档/ }).click();
await expect(page.getByLabel('角色与指令')).toHaveValue('使用简洁自然的中文。先理解我的目的,再协助梳理结构。');
} finally {
await closeElectronApp(app);
}
});

View File

@@ -81,7 +81,7 @@ test.describe('Makelore module navigation without setup flow', () => {
}
});
test('keeps the three module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => {
test('keeps the four module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
@@ -103,7 +103,7 @@ test.describe('Makelore module navigation without setup flow', () => {
}));
const initialMetrics = await readCardMetrics();
expect(initialMetrics).toHaveLength(3);
expect(initialMetrics).toHaveLength(4);
await expect(moduleCards.first().locator('.module-option-card-arrow')).toHaveCount(0);
await expect(moduleCards.first().locator('.module-option-artwork')).toHaveCount(0);
await expect.poll(async () => await moduleCards.first().evaluate((element) => {
@@ -155,7 +155,7 @@ test.describe('Makelore module navigation without setup flow', () => {
await expect.poll(async () => (await readCardMetrics())[0]?.width ?? 0).toBeLessThan(initialWidth - 1);
const resizedMetrics = await readCardMetrics();
expect(resizedMetrics).toHaveLength(3);
expect(resizedMetrics).toHaveLength(4);
expect(Math.max(...resizedMetrics.map((card) => card.left)) - Math.min(...resizedMetrics.map((card) => card.left))).toBeLessThan(1);
expect(resizedMetrics.map((card) => card.top)).toEqual(
[...resizedMetrics].sort((top, bottom) => top.top - bottom.top).map((card) => card.top),
@@ -195,7 +195,7 @@ test.describe('Makelore module navigation without setup flow', () => {
const page = await getStableWindow(app);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(3);
await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(4);
await expect(page.getByTestId('ai-module-option-learning')).toHaveCount(0);
await page.evaluate(() => {
window.location.hash = '#/learning';

View File

@@ -88,6 +88,7 @@ describe('auth host api routes', () => {
design: true,
learning: false,
robot: true,
cloud_agents: true,
},
}), { status: 200 }),
);
@@ -116,6 +117,7 @@ describe('auth host api routes', () => {
programming: false,
design: true,
robot: true,
cloud_agents: true,
},
});
expect(response.json()).not.toHaveProperty('moduleAccess.learning');
@@ -162,6 +164,7 @@ describe('auth host api routes', () => {
programming: true,
design: true,
robot: true,
cloud_agents: true,
},
});
});

View File

@@ -25,6 +25,7 @@ function resetAuthStore() {
programming: true,
design: true,
robot: true,
cloud_agents: true,
},
});
}
@@ -88,6 +89,7 @@ describe('auth store', () => {
programming: true,
design: false,
robot: false,
cloud_agents: true,
},
});
@@ -128,6 +130,7 @@ describe('auth store', () => {
programming: true,
design: false,
robot: false,
cloud_agents: true,
});
expect(window.localStorage.getItem('niancode-auth')).not.toContain(
'must-not-return-to-renderer-storage',
@@ -177,6 +180,7 @@ describe('auth store', () => {
programming: true,
design: true,
robot: false,
cloud_agents: true,
},
});
});
@@ -214,6 +218,7 @@ describe('auth store', () => {
programming: true,
design: true,
robot: true,
cloud_agents: true,
},
});
});
@@ -257,6 +262,7 @@ describe('auth store', () => {
programming: true,
design: false,
robot: true,
cloud_agents: true,
});
});
@@ -310,6 +316,7 @@ describe('auth store', () => {
programming: true,
design: false,
robot: true,
cloud_agents: true,
},
});
expect(useAuthStore.getState().isAuthenticated()).toBe(true);
@@ -416,6 +423,7 @@ describe('auth store', () => {
programming: true,
design: false,
robot: true,
cloud_agents: true,
},
user: {
username: 'zhangsan',
@@ -438,6 +446,7 @@ describe('auth store', () => {
programming: true,
design: true,
robot: true,
cloud_agents: true,
},
});
});
@@ -480,6 +489,7 @@ describe('auth store', () => {
programming: true,
design: true,
robot: true,
cloud_agents: true,
},
});
});
@@ -943,6 +953,7 @@ describe('auth store', () => {
programming: true,
design: true,
robot: true,
cloud_agents: true,
},
});
});

View File

@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CloudAgentsModule } from '@electron/services/cloud-agents';
import { clearWorksSquareSession, storeWorksSquareSession } from '@electron/services/works-square-session';
const draft = {
slug: 'ml-' + 'a'.repeat(32), name: '写作搭档', purpose: '帮助写作', system_prompt: '简明表达',
draft_revision: 1, updated_at: '2026-09-10T00:00:00Z',
};
const input = { operation_id: '12345678-1234-1234-1234-123456789012', name: draft.name, purpose: draft.purpose };
const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status });
const session = () => json({
access_token: 'yuxi-secret', token_type: 'bearer', scope: 'makelore-agent-drafts',
expires_at: Math.floor(Date.now() / 1000) + 120, api_base_url: 'https://agents.example.test',
});
function login(key = 'a'.repeat(64)) {
storeWorksSquareSession({ accessToken: 'ws-secret', expiresAt: Date.now() + 600000, accountPartitionKey: key });
}
const instances: CloudAgentsModule[] = [];
function moduleFor(fetchImpl: typeof fetch) {
const module = new CloudAgentsModule(fetchImpl); instances.push(module); return module;
}
beforeEach(() => { clearWorksSquareSession(); login(); });
afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); });
describe('Main cloud Agents boundary', () => {
it('keeps credentials in Main, projects the DTO and sends only creator-independent inputs', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(session())
.mockResolvedValueOnce(json({ ...draft, access_token: 'do-not-project', owner: 'private' }))
.mockResolvedValueOnce(json({ agents: [draft], next_cursor: null }));
const module = moduleFor(fetchImpl);
expect(await module.create({ ...input, payer: 'other', account_id: 'other' })).toEqual(draft);
expect(await module.list()).toEqual({ agents: [draft], next_cursor: null });
expect(fetchImpl).toHaveBeenCalledTimes(3);
expect(fetchImpl.mock.calls[0][1].headers.Authorization).toBe('Bearer ws-secret');
expect(fetchImpl.mock.calls[1][1].headers.Authorization).toBe('Bearer yuxi-secret');
expect(JSON.parse(fetchImpl.mock.calls[1][1].body)).toEqual(input);
expect(fetchImpl.mock.calls[1][1].redirect).toBe('error');
});
it('rejects the old account response after switching accounts', async () => {
let finish!: (response: Response) => void;
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce(() => new Promise<Response>((resolve) => { finish = resolve; }));
const result = moduleFor(fetchImpl).list();
const rejection = expect(result).rejects.toMatchObject({ code: 'account_changed' });
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
login('b'.repeat(64));
finish(json({ agents: [draft], next_cursor: null }));
await rejection;
});
it('does not automatically repeat a possibly committed create and preserves an explicit retry operation', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response ws-secret'))
.mockResolvedValueOnce(json(draft));
const module = moduleFor(fetchImpl);
await expect(module.create(input)).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(await module.create(input)).toEqual(draft);
expect(fetchImpl.mock.calls[2][1].body).toBe(fetchImpl.mock.calls[1][1].body);
});
it('passes revision conflicts without exposing upstream diagnostics', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({
detail: { code: 'draft_revision_conflict', message: 'raw yuxi-secret' },
}, 409));
await expect(moduleFor(fetchImpl).save(draft.slug, {
expected_revision: 1, name: draft.name, purpose: draft.purpose, system_prompt: 'new',
})).rejects.toMatchObject({ status: 409, code: 'draft_revision_conflict', message: '草稿已在其他设备更新,你的输入已保留' });
});
it('rejects malformed draft responses as service failures', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ ...draft, draft_revision: 'oops' }));
await expect(moduleFor(fetchImpl).get(draft.slug)).rejects.toMatchObject({ status: 502 });
});
it('rejects missing login before any network request', async () => {
clearWorksSquareSession();
const fetchImpl = vi.fn();
await expect(moduleFor(fetchImpl).list()).rejects.toMatchObject({ status: 401 });
expect(fetchImpl).not.toHaveBeenCalled();
});
it('times out a stalled response body and projects a safe error', async () => {
vi.useFakeTimers();
const fetchImpl = vi.fn().mockResolvedValueOnce({ text: () => new Promise(() => {}) });
const result = expect(moduleFor(fetchImpl).list()).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
await vi.advanceTimersByTimeAsync(30000);
await result;
});
});

View File

@@ -0,0 +1,122 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import CloudAgents from '@/pages/CloudAgents';
const api = vi.hoisted(() => ({ list: vi.fn(), create: vi.fn(), get: vi.fn(), save: vi.fn() }));
const auth = vi.hoisted(() => ({ user: { userId: 'creator' } }));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: typeof auth) => unknown) => selector(auth) }));
const draft = {
slug: 'ml-' + 'a'.repeat(32), name: '写作搭档', purpose: '帮助写作', system_prompt: '原始指令',
draft_revision: 1, updated_at: '2026-09-10T00:00:00Z',
};
function open() {
const router = createMemoryRouter([
{ path: '/cloud-agents', element: <CloudAgents /> },
{ path: '/module-select', element: <div></div> },
], { initialEntries: ['/cloud-agents'] });
const view = render(<RouterProvider router={router} />);
return { router, ...view };
}
beforeEach(() => {
vi.resetAllMocks(); auth.user.userId = 'creator';
api.list.mockResolvedValue({ agents: [draft], next_cursor: null });
});
afterEach(cleanup);
it('keeps creation input and its uncertain operation when an existing card is clicked', async () => {
api.create.mockRejectedValueOnce(new Error('连接中断')).mockResolvedValueOnce(draft);
open();
const existing = await screen.findByRole('button', { name: /写作搭档/ });
fireEvent.click(screen.getByRole('button', { name: '创建智能体' }));
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新搭档' } });
fireEvent.change(screen.getByLabelText('用途'), { target: { value: '新用途' } });
fireEvent.click(existing);
expect(screen.queryByTestId('cloud-agent-editor')).not.toBeInTheDocument();
expect(screen.getByLabelText('名称')).toHaveValue('新搭档');
fireEvent.click(screen.getByRole('button', { name: '创建草稿' }));
await screen.findByRole('alert');
fireEvent.click(existing);
expect(screen.queryByTestId('cloud-agent-editor')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '重试创建' }));
await screen.findByTestId('cloud-agent-editor');
expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]);
});
it('keeps the same creation operation after a lost response', async () => {
api.list.mockResolvedValue({ agents: [], next_cursor: null });
api.create.mockRejectedValueOnce(new Error('连接中断')).mockResolvedValueOnce(draft);
open();
fireEvent.click(await screen.findByText('创建第一个智能体'));
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '写作搭档' } });
fireEvent.change(screen.getByLabelText('用途'), { target: { value: '帮助写作' } });
fireEvent.click(screen.getByRole('button', { name: '创建草稿' }));
await screen.findByRole('alert');
fireEvent.click(screen.getByRole('button', { name: '重试创建' }));
await screen.findByTestId('cloud-agent-editor');
expect(api.create).toHaveBeenCalledTimes(2);
expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]);
});
it('preserves local input on conflicts and requires an explicit choice before retry', async () => {
api.save.mockRejectedValueOnce(new Error('草稿已更新'));
api.get.mockResolvedValue({ ...draft, draft_revision: 2, system_prompt: '其他设备内容' });
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
fireEvent.change(screen.getByLabelText('角色与指令'), { target: { value: '我的修改' } });
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await screen.findByText('云端已有修订 2本地输入仍保留');
expect(screen.getByLabelText('角色与指令')).toHaveValue('我的修改');
expect(screen.getByRole('button', { name: '保存草稿' })).toBeDisabled();
fireEvent.click(screen.getByText('保留我的内容,继续编辑'));
api.save.mockResolvedValue({ ...draft, draft_revision: 3, system_prompt: '我的修改' });
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await screen.findByRole('button', { name: '已保存' });
expect(api.save.mock.calls[1][1]).toMatchObject({ expected_revision: 2, system_prompt: '我的修改' });
});
it('blocks module navigation with unsaved edits, cancels leaving, then saves before proceeding', async () => {
const { router } = open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的名称' } });
await act(async () => { await router.navigate('/module-select'); });
expect(screen.getByRole('dialog', { name: '未保存的修改' })).toBeVisible();
expect(router.state.location.pathname).toBe('/cloud-agents');
fireEvent.click(screen.getByText('继续编辑'));
expect(screen.getByLabelText('名称')).toHaveValue('新的名称');
await act(async () => { await router.navigate('/module-select'); });
api.save.mockResolvedValue({ ...draft, name: '新的名称', draft_revision: 2 });
fireEvent.click(screen.getByText('保存并离开'));
await screen.findByText('模块首页');
expect(api.save).toHaveBeenCalledTimes(1);
});
it('clears the previous account workspace and ignores its delayed list', async () => {
let finish!: (value: unknown) => void;
api.list.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }))
.mockResolvedValue({ agents: [], next_cursor: null });
const view = open();
auth.user.userId = 'other';
view.rerender(<RouterProvider key="other-account" router={view.router} />);
await screen.findByText('你的第一个智能体,从这里开始');
await act(async () => finish({ agents: [draft], next_cursor: null }));
await waitFor(() => expect(screen.queryByText('写作搭档')).not.toBeInTheDocument());
});
it('warns before leaving an uncertain creation and keeps its retry intent', async () => {
api.list.mockResolvedValue({ agents: [], next_cursor: null });
api.create.mockRejectedValue(new Error('连接中断'));
const { router } = open();
fireEvent.click(await screen.findByText('创建第一个智能体'));
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '写作搭档' } });
fireEvent.change(screen.getByLabelText('用途'), { target: { value: '帮助写作' } });
fireEvent.click(screen.getByRole('button', { name: '创建草稿' }));
await screen.findByRole('alert');
await act(async () => { await router.navigate('/module-select'); });
expect(screen.getByRole('dialog', { name: '离开创建' })).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '继续创建' }));
fireEvent.click(screen.getByRole('button', { name: '重试创建' }));
await waitFor(() => expect(api.create).toHaveBeenCalledTimes(2));
expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]);
});

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { getGuardedAiModuleForPath, isAiModuleAllowed, isProgrammingProviderRoute } from '@/lib/ai-modules';
import {
DEFAULT_MODULE_ACCESS,
MODULE_ACCESS_KEYS,
@@ -6,12 +7,19 @@ import {
} from '../../shared/module-access';
describe('module access projection', () => {
it('contains only the three supported product modules', () => {
expect(MODULE_ACCESS_KEYS).toEqual(['programming', 'design', 'robot']);
it('honors an explicit cloud Agent denial and does not initialize Code for its route', () => {
const access = normalizeModuleAccess({ cloud_agents: false });
expect(isAiModuleAllowed('cloud_agents', access)).toBe(false);
expect(getGuardedAiModuleForPath('/cloud-agents')).toBe('cloud_agents');
expect(isProgrammingProviderRoute('/cloud-agents')).toBe(false);
});
it('contains only the four supported product modules', () => {
expect(MODULE_ACCESS_KEYS).toEqual(['programming', 'design', 'robot', 'cloud_agents']);
expect(DEFAULT_MODULE_ACCESS).toEqual({
programming: true,
design: true,
robot: true,
cloud_agents: true,
});
});
@@ -27,6 +35,7 @@ describe('module access projection', () => {
programming: false,
design: true,
robot: false,
cloud_agents: true,
});
expect(access).not.toHaveProperty('learning');
});