完善客户端模块与工作区能力
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-08-13 19:45:30 +08:00
parent 0148b91a15
commit 22add3f01f
97 changed files with 4756 additions and 3226 deletions

View File

@@ -0,0 +1,133 @@
import { browserImageCodec } from './browser-image-codec';
export const AGENT_AVATAR_MAX_DIMENSION = 256;
export const AGENT_AVATAR_MAX_SOURCE_BYTES = 32 * 1024 * 1024;
export const AGENT_AVATAR_WEBP_QUALITY = 0.8;
export const AGENT_AVATAR_SUPPORTED_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/webp',
] as const;
export type AgentAvatarMimeType = typeof AGENT_AVATAR_SUPPORTED_MIME_TYPES[number];
export type PreparedAgentAvatar = {
fileName: string;
mimeType: AgentAvatarMimeType;
previewUrl: string;
width: number;
height: number;
bytes: number;
};
function isSupportedMimeType(value: string): value is AgentAvatarMimeType {
return (AGENT_AVATAR_SUPPORTED_MIME_TYPES as readonly string[]).includes(value);
}
function fileExtension(mimeType: AgentAvatarMimeType): string {
if (mimeType === 'image/jpeg') return 'jpg';
if (mimeType === 'image/png') return 'png';
return 'webp';
}
function bytesToBase64(bytes: Uint8Array): string {
let result = '';
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
result += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return btoa(result);
}
async function blobToDataUrl(blob: Blob, mimeType: AgentAvatarMimeType): Promise<string> {
const dataBase64 = bytesToBase64(new Uint8Array(await blob.arrayBuffer()));
return `data:${mimeType};base64,${dataBase64}`;
}
function encodeCanvas(
canvas: HTMLCanvasElement,
mimeType: AgentAvatarMimeType,
quality?: number,
): Promise<Blob | null> {
return new Promise((resolve, reject) => {
try {
canvas.toBlob(resolve, mimeType, quality);
} catch (error) {
reject(error);
}
});
}
async function encodeAvatarCanvas(canvas: HTMLCanvasElement): Promise<{
blob: Blob;
mimeType: AgentAvatarMimeType;
}> {
const webp = await encodeCanvas(canvas, 'image/webp', AGENT_AVATAR_WEBP_QUALITY);
if (webp?.type.trim().toLowerCase() === 'image/webp') {
return { blob: webp, mimeType: 'image/webp' };
}
const png = await encodeCanvas(canvas, 'image/png');
if (png) return { blob: png, mimeType: 'image/png' };
throw new Error('头像图片编码失败');
}
export async function prepareAgentAvatar(file: File): Promise<PreparedAgentAvatar> {
const mimeType = file.type.trim().toLowerCase();
if (!isSupportedMimeType(mimeType)) {
throw new Error('伙伴头像仅支持 PNG、JPEG 或 WebP 图片');
}
if (file.size > AGENT_AVATAR_MAX_SOURCE_BYTES) {
throw new Error('伙伴头像图片不能超过 32 MB');
}
const frameCount = await browserImageCodec.inspectFrameCount(file, mimeType);
if (frameCount !== 1) {
throw new Error(frameCount && frameCount > 1 ? '伙伴头像不支持动态图片' : '无法确认伙伴头像为静态图片');
}
const decoded = await browserImageCodec.decode(file, {
imageOrientation: 'from-image',
});
const canvas = document.createElement('canvas');
const side = Math.min(decoded.width, decoded.height);
const targetSize = Math.max(1, Math.min(AGENT_AVATAR_MAX_DIMENSION, Math.floor(side)));
const sourceX = Math.max(0, (decoded.width - side) / 2);
const sourceY = Math.max(0, (decoded.height - side) / 2);
canvas.width = targetSize;
canvas.height = targetSize;
try {
const context = canvas.getContext('2d');
if (!context) throw new Error('伙伴头像图片处理失败');
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.clearRect(0, 0, targetSize, targetSize);
context.drawImage(
decoded.source,
sourceX,
sourceY,
side,
side,
0,
0,
targetSize,
targetSize,
);
const encoded = await encodeAvatarCanvas(canvas);
return {
fileName: `avatar.${fileExtension(encoded.mimeType)}`,
mimeType: encoded.mimeType,
previewUrl: await blobToDataUrl(encoded.blob, encoded.mimeType),
width: targetSize,
height: targetSize,
bytes: encoded.blob.size,
};
} finally {
decoded.dispose();
canvas.width = 1;
canvas.height = 1;
}
}

View File

@@ -1,3 +1,5 @@
import { isProjectAgentAvatarDataUrl } from '../../shared/project-config';
const avatarModules = import.meta.glob('../assets/agent-avatars/avatar-*.png', {
eager: true,
import: 'default',
@@ -18,6 +20,10 @@ export const agentAvatarOptions: AgentAvatarOption[] = Array.from({ length: 16 }
};
});
export function getAgentAvatarSrc(avatarId: string | null | undefined): string {
export function getAgentAvatarSrc(
avatarId: string | null | undefined,
avatarDataUrl?: string | null,
): string {
if (isProjectAgentAvatarDataUrl(avatarDataUrl)) return avatarDataUrl;
return agentAvatarOptions.find((option) => option.id === avatarId)?.src ?? agentAvatarOptions[0]!.src;
}

View File

@@ -1,4 +1,5 @@
import { hostApiFetch } from '@/lib/host-api';
import type { AgentSessionData } from '../../shared/agent-session';
export type AgentProfileGender = 'male' | 'female' | 'other' | null;
@@ -6,6 +7,7 @@ export type AgentProfileRead = {
display_name: string | null;
age: number | null;
gender: AgentProfileGender;
avatar_url?: string | null;
share_age_with_agents: boolean;
share_gender_with_agents: boolean;
analysis_enabled: boolean;
@@ -22,34 +24,74 @@ export type AgentProfileUpdate = {
share_gender_with_agents: boolean;
analysis_enabled: boolean;
version: number;
session_data?: AgentSessionData;
};
type AgentProfileEnvelope = {
success?: boolean;
status?: number;
code?: string;
profile?: AgentProfileRead;
error?: string;
detail?: unknown;
};
export type AgentAvatarUpload = {
fileName: string;
mimeType: string;
dataBase64: string;
};
type AgentAvatarEnvelope = {
success?: boolean;
status?: number;
code?: string;
avatar_url?: string | null;
profile?: Pick<AgentProfileRead, 'avatar_url'>;
error?: string;
detail?: unknown;
};
export class AgentProfileApiError extends Error {
readonly status: number;
readonly detail: unknown;
readonly code: string | null;
constructor(status: number, message: string, detail?: unknown) {
constructor(status: number, message: string, detail?: unknown, code?: string | null) {
super(message);
this.name = 'AgentProfileApiError';
this.status = status;
this.detail = detail;
this.code = code?.trim() || readDetailCode(detail);
}
}
export function getAgentProfileApiErrorMessage(error: AgentProfileApiError): string {
if (error.status === 401) return '登录已过期,请重新登录后再同步个人资料。';
if (error.code === 'avatar_unsupported_type') return '头像仅支持 PNG、JPEG 或 WebP 图片。';
if (error.code === 'avatar_file_too_large') return '头像文件超过服务端大小限制。';
if (error.code === 'avatar_invalid_file') return '头像文件无效或无法读取。';
if (error.code === 'avatar_storage_unavailable') return '头像存储服务暂时不可用,请稍后重试。';
if (error.status === 503) {
return readDetailMessage(error.detail)
? error.message
: '个人资料服务暂时不可用,请稍后重试。';
}
return error.message;
}
function readDetailCode(detail: unknown): string | null {
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) return null;
const value = (detail as Record<string, unknown>).code;
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function readDetailMessage(detail: unknown): string | null {
if (typeof detail === 'string' && detail.trim()) return detail.trim();
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) return null;
const record = detail as Record<string, unknown>;
for (const field of ['message', 'error', 'code']) {
for (const field of ['message', 'msg', 'error_description', 'error']) {
const value = record[field];
if (typeof value === 'string' && value.trim()) return value.trim();
}
@@ -71,10 +113,12 @@ async function requestAgentProfile(
if (!response?.success || !response.profile) {
const status = typeof response?.status === 'number' ? response.status : 502;
const detailMessage = readDetailMessage(response?.detail);
const code = response?.code?.trim() || readDetailCode(response?.detail);
throw new AgentProfileApiError(
status,
response?.error || detailMessage || `Agent Profile request failed (${status})`,
response?.detail,
code,
);
}
@@ -94,3 +138,49 @@ export function putAgentProfile(
body: JSON.stringify(update),
});
}
function readAvatarUrl(response: AgentAvatarEnvelope): string | null {
const value = response.avatar_url ?? response.profile?.avatar_url;
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
async function requestAgentAvatar(
accessToken: string,
init: RequestInit = {},
): Promise<string | null> {
const response = await hostApiFetch<AgentAvatarEnvelope>('/api/works/user/avatar', {
...init,
headers: {
'X-NianCode-Access-Token': accessToken,
...init.headers,
},
});
if (!response?.success) {
const status = typeof response?.status === 'number' ? response.status : 502;
const detailMessage = readDetailMessage(response?.detail);
const code = response?.code?.trim() || readDetailCode(response?.detail);
throw new AgentProfileApiError(
status,
response?.error || detailMessage || `Agent Avatar request failed (${status})`,
response?.detail,
code,
);
}
return readAvatarUrl(response);
}
export function uploadAgentAvatar(
accessToken: string,
upload: AgentAvatarUpload,
): Promise<string | null> {
return requestAgentAvatar(accessToken, {
method: 'POST',
body: JSON.stringify(upload),
});
}
export function deleteAgentAvatar(accessToken: string): Promise<string | null> {
return requestAgentAvatar(accessToken, { method: 'DELETE' });
}

View File

@@ -0,0 +1,380 @@
import { extractText } from '@/pages/Chat/message-utils';
import { useAuthStore } from '@/stores/auth';
import {
getProfileAccountKey,
isUserProfileComplete,
useUserProfileStore,
} from '@/stores/user-profile';
import type { RawMessage } from '@/types/chat';
import type {
AgentSessionData,
AgentSessionMessage,
AgentSessionMessageRole,
} from '../../shared/agent-session';
const PENDING_AGENT_SESSION_SYNC_STORAGE_KEY = 'niancode-agent-session-sync-pending';
const SESSION_UPLOAD_DEBOUNCE_MS = 1_000;
const SESSION_UPLOAD_RETRY_DELAYS_MS = [5_000, 30_000, 120_000, 300_000];
type PendingUpload = {
userId: string;
data: AgentSessionData;
retryCount: number;
};
const pendingUploads = new Map<string, PendingUpload>();
const uploadTimers = new Map<string, ReturnType<typeof setTimeout>>();
const inFlightUploads = new Map<string, Promise<void>>();
let pendingUploadsHydrated = false;
const ANSI_ESCAPE_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g');
const FENCED_CODE_PATTERN = /```[\s\S]*?(?:```|$)/g;
const TILDE_FENCED_CODE_PATTERN = /~~~[\s\S]*?(?:~~~|$)/g;
const INLINE_CODE_PATTERN = /`[^`\n]*`/g;
const MEDIA_MARKER_PATTERN = /\b(?:MEDIA|media):(?:\/|~\/)[^\s"'<>()[\]{},。;:!?、]+/g;
const ARTIFACT_URL_PATTERN = /https?:\/\/[^\s"'<>()[\]{},。;:!?、]+\.(?:png|jpe?g|gif|webp|bmp|avif|svg|pdf|docx?|xlsx?|pptx?|txt|csv|md|zip|tar|gz|rar|7z|mp3|wav|ogg|aac|flac|m4a|mp4|mov|avi|mkv|webm|m4v)(?:[?#][^\s"'<>()[\]{},。;:!?、]+)?/gi;
const ABSOLUTE_PATH_PATTERN = /(?<![A-Za-z0-9_])(?:~\/|\/(?:Users|home|private|tmp|var|opt|mnt|Volumes|Documents|Desktop|Downloads|workspace|workspaces|repo|project|src|dist|build)(?:\/|[^\s"'`<>()[\]{},。;:!?、])[^\s"'`<>()[\]{},。;:!?、]*|[A-Za-z]:[\\/][^\s"'`<>()[\]{},。;:!?、]*|\\\\[^\s"'`<>()[\]{},。;:!?、]+)/g;
const RELATIVE_PATH_PATTERN = /(?<![A-Za-z0-9_])(?:\.\.?[\\/]|(?:src|dist|build|public|app|lib|components|pages|tests|electron|shared|resources)[\\/])[^\s"'`<>()[\]{},。;:!?、]+/gi;
const SOURCE_FILE_NAME_PATTERN = /(?<![A-Za-z0-9_])[A-Za-z0-9_-]+\.(?:c|cc|cpp|cs|css|go|h|hpp|html?|java|js|json|jsx|kt|md|php|py|rb|rs|scss|sh|sql|swift|ts|tsx|vue|xml|yaml|yml)(?![A-Za-z0-9_])/gi;
const CODE_LIKE_LINE_PATTERN = /^\s*(?:#include\b|(?:const|let|var|function|class|interface|type|import|export|def|async\s+function)\b|(?:SELECT|INSERT\s+INTO|UPDATE\s+.+\s+SET|DELETE\s+FROM)\b)/;
function normalizeWhitespace(value: string): string {
return value
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function stripLogLines(value: string): string {
return value
.split('\n')
.filter((line) => {
const trimmed = line.trim();
if (!trimmed) return true;
return !(
/^\[(?:\d{4}-\d{2}-\d{2}[T ][^\]]+|(?:DEBUG|INFO|WARN|ERROR|TRACE)[^\]]*)\]/i.test(trimmed)
|| /^(?:DEBUG|INFO|WARN|ERROR|TRACE)\s*(?:\||:)/i.test(trimmed)
|| /^at\s+[^\s(]+\s*\([^)]*\)/.test(trimmed)
|| /^(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+\//.test(trimmed)
);
})
.join('\n');
}
function stripCodeLikeLines(value: string): string {
return value
.split('\n')
.filter((line) => !CODE_LIKE_LINE_PATTERN.test(line))
.join('\n');
}
/**
* Keep only the natural-language part of a user/assistant message before it
* leaves the local workspace. Runtime metadata and tool parts are removed by
* the existing message projection; this second pass removes code, paths,
* logs, and artifact links from visible text as well.
*/
export function sanitizeAgentSessionText(value: string): string {
const withoutRuntimeArtifacts = value
.replace(ANSI_ESCAPE_PATTERN, '')
.replace(FENCED_CODE_PATTERN, '\n')
.replace(TILDE_FENCED_CODE_PATTERN, '\n')
.replace(INLINE_CODE_PATTERN, '')
.replace(MEDIA_MARKER_PATTERN, '')
.replace(ARTIFACT_URL_PATTERN, '')
.replace(ABSOLUTE_PATH_PATTERN, '')
.replace(RELATIVE_PATH_PATTERN, '')
.replace(SOURCE_FILE_NAME_PATTERN, '');
return normalizeWhitespace(stripCodeLikeLines(stripLogLines(withoutRuntimeArtifacts)));
}
function getMessageText(message: RawMessage): string {
// `extractText` keeps text blocks while ignoring thinking/tool/image parts;
// using the Codex final-answer projection here would incorrectly discard
// natural-language narration that appears before a tool call.
const visibleText = extractText(message);
return sanitizeAgentSessionText(visibleText);
}
function toIsoTimestamp(value: unknown): string | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
const milliseconds = Math.abs(value) < 1_000_000_000_000 ? value * 1_000 : value;
const date = new Date(milliseconds);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
if (typeof value === 'string' && value.trim()) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
return undefined;
}
function getMessageTimestamp(message: RawMessage): string | undefined {
const record = message as RawMessage & {
createdAt?: unknown;
created_at?: unknown;
updatedAt?: unknown;
updated_at?: unknown;
};
return toIsoTimestamp(message.timestamp)
?? toIsoTimestamp(record.createdAt)
?? toIsoTimestamp(record.created_at)
?? toIsoTimestamp(record.updatedAt)
?? toIsoTimestamp(record.updated_at);
}
function getMessageId(message: RawMessage): string | undefined {
return typeof message.id === 'string' && message.id.trim()
? message.id.trim()
: undefined;
}
function isSyncableRole(role: RawMessage['role']): role is AgentSessionMessageRole {
return role === 'user' || role === 'assistant';
}
/** Build a wire-safe full snapshot for one locally completed session. */
export function buildAgentSessionData(
projectId: string,
sessionId: string,
messages: readonly RawMessage[],
updatedAt = new Date().toISOString(),
): AgentSessionData | null {
const normalizedProjectId = projectId.trim();
const normalizedSessionId = sessionId.trim();
if (!normalizedProjectId || !normalizedSessionId) return null;
const normalizedMessages: AgentSessionMessage[] = [];
const indexById = new Map<string, number>();
for (const message of messages) {
if (!isSyncableRole(message.role) || message.isError) continue;
const text = getMessageText(message);
if (!text) continue;
const next: AgentSessionMessage = {
...(getMessageId(message) ? { id: getMessageId(message) } : {}),
role: message.role,
...(getMessageTimestamp(message) ? { created_at: getMessageTimestamp(message) } : {}),
text,
};
const messageId = next.id;
if (messageId && indexById.has(messageId)) {
normalizedMessages[indexById.get(messageId)!] = next;
continue;
}
if (messageId) indexById.set(messageId, normalizedMessages.length);
normalizedMessages.push(next);
}
if (normalizedMessages.length === 0) return null;
return {
project_id: normalizedProjectId,
session_id: normalizedSessionId,
updated_at: updatedAt,
messages: normalizedMessages,
};
}
function getStorage(): Storage | null {
if (typeof window === 'undefined') return null;
try {
return window.localStorage;
} catch {
return null;
}
}
function isAgentSessionData(value: unknown): value is AgentSessionData {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
if (
typeof record.project_id !== 'string'
|| typeof record.session_id !== 'string'
|| typeof record.updated_at !== 'string'
|| !Array.isArray(record.messages)
) return false;
return record.messages.every((message) => (
Boolean(message)
&& typeof message === 'object'
&& !Array.isArray(message)
&& ((message as Record<string, unknown>).role === 'user'
|| (message as Record<string, unknown>).role === 'assistant')
&& typeof (message as Record<string, unknown>).text === 'string'
));
}
function getCurrentAccountKey(): string | null {
const authUser = useAuthStore.getState().user;
return getProfileAccountKey(authUser?.userId ?? authUser?.username);
}
function getUploadKey(userId: string, data: AgentSessionData): string {
return `${userId}:${data.project_id}:${data.session_id}`;
}
function hydratePendingUploads(): void {
if (pendingUploadsHydrated) return;
pendingUploadsHydrated = true;
const storage = getStorage();
if (!storage) return;
try {
const parsed = JSON.parse(storage.getItem(PENDING_AGENT_SESSION_SYNC_STORAGE_KEY) ?? '[]') as unknown;
if (!Array.isArray(parsed)) return;
for (const value of parsed) {
const record = value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
const userId = getProfileAccountKey(
typeof record?.userId === 'string' ? record.userId : undefined,
);
const data = record?.data;
// Queue entries are account-bound. Do not guess an owner for records
// written by an older build, otherwise a different login could receive
// another account's local conversation data.
if (!userId || !isAgentSessionData(data)) continue;
const retryCount = typeof record?.retryCount === 'number' && Number.isFinite(record.retryCount)
? Math.max(0, Math.floor(record.retryCount))
: 0;
pendingUploads.set(getUploadKey(userId, data), { userId, data, retryCount });
}
} catch {
// A malformed retry queue must not prevent the app from starting.
}
}
function persistPendingUploads(): void {
const storage = getStorage();
if (!storage) return;
try {
storage.setItem(
PENDING_AGENT_SESSION_SYNC_STORAGE_KEY,
JSON.stringify([...pendingUploads.values()]),
);
} catch {
// Quota/private-mode failures should not affect the active conversation.
}
}
function clearUploadTimer(key: string): void {
const timer = uploadTimers.get(key);
if (timer === undefined) return;
clearTimeout(timer);
uploadTimers.delete(key);
}
function scheduleUpload(key: string, delayMs: number): void {
clearUploadTimer(key);
uploadTimers.set(key, setTimeout(() => {
uploadTimers.delete(key);
void flushUpload(key);
}, delayMs));
}
function getRetryDelay(retryCount: number): number {
return SESSION_UPLOAD_RETRY_DELAYS_MS[
Math.min(Math.max(retryCount - 1, 0), SESSION_UPLOAD_RETRY_DELAYS_MS.length - 1)
];
}
async function uploadWithCurrentProfile(userId: string, data: AgentSessionData): Promise<void> {
if (getCurrentAccountKey() !== userId) {
throw new Error('Authenticated account changed during Agent session sync');
}
const accessToken = await useAuthStore.getState().getValidAccessToken();
if (!accessToken) throw new Error('No authenticated session for Agent session sync');
if (getCurrentAccountKey() !== userId) {
throw new Error('Authenticated account changed during Agent session sync');
}
const profile = useUserProfileStore.getState().profilesByUserId[userId];
// Session observation must never pull cloud profile data into the local
// workspace. The normal profile bootstrap owns that read; until it has
// produced a local profile, this upload remains queued for a retry.
if (!isUserProfileComplete(profile)) {
throw new Error('Agent profile is not complete');
}
await useUserProfileStore.getState().pushSessionData(userId, accessToken, data);
}
async function flushUpload(key: string): Promise<void> {
const existing = inFlightUploads.get(key);
if (existing) return existing;
const task = (async () => {
hydratePendingUploads();
const pending = pendingUploads.get(key);
if (!pending) return;
if (getCurrentAccountKey() !== pending.userId) return;
pendingUploads.delete(key);
persistPendingUploads();
try {
await uploadWithCurrentProfile(pending.userId, pending.data);
} catch {
if (!pendingUploads.has(key)) {
const retryCount = pending.retryCount + 1;
pendingUploads.set(key, { userId: pending.userId, data: pending.data, retryCount });
persistPendingUploads();
if (getCurrentAccountKey() === pending.userId) {
scheduleUpload(key, getRetryDelay(retryCount));
}
}
return;
}
if (pendingUploads.has(key)) {
scheduleUpload(key, SESSION_UPLOAD_DEBOUNCE_MS);
}
})();
inFlightUploads.set(key, task);
try {
await task;
} finally {
if (inFlightUploads.get(key) === task) inFlightUploads.delete(key);
}
}
/** Queue a completed local session snapshot without blocking the chat turn. */
export function queueAgentSessionSync(
projectId: string,
sessionId: string,
messages: readonly RawMessage[],
): void {
const data = buildAgentSessionData(projectId, sessionId, messages);
if (!data) return;
const userId = getCurrentAccountKey();
if (!userId) return;
hydratePendingUploads();
const key = getUploadKey(userId, data);
pendingUploads.set(key, { userId, data, retryCount: 0 });
persistPendingUploads();
scheduleUpload(key, SESSION_UPLOAD_DEBOUNCE_MS);
}
/** Flush persisted session snapshots after login or profile bootstrap. */
export async function flushPendingAgentSessionSync(): Promise<void> {
hydratePendingUploads();
const userId = getCurrentAccountKey();
if (!userId) return;
const keys = [...pendingUploads.entries()]
.filter(([, pending]) => pending.userId === userId)
.map(([key]) => key);
await Promise.all(keys.map((key) => {
clearUploadTimer(key);
return flushUpload(key);
}));
}
/** Reset only in-memory sync state for isolated unit tests. */
export function resetAgentSessionSyncForTests(): void {
for (const timer of uploadTimers.values()) clearTimeout(timer);
uploadTimers.clear();
pendingUploads.clear();
inFlightUploads.clear();
pendingUploadsHydrated = false;
}

View File

@@ -1,9 +1,9 @@
import type { LucideIcon } from 'lucide-react';
import { Code2, Paintbrush, Sigma } from 'lucide-react';
import { Bot, Code2, Paintbrush, Sigma } from 'lucide-react';
export const AI_MODULE_SELECTION_PATH = '/module-select';
export type AiModuleId = 'programming' | 'painting' | 'learning';
export type AiModuleId = 'programming' | 'painting' | 'learning' | 'robot';
export type AiModuleDefinition = {
id: AiModuleId;
@@ -13,6 +13,7 @@ export type AiModuleDefinition = {
route: string | null;
enabled: boolean;
Icon: LucideIcon;
switcherLabel?: string;
};
export const aiModules: readonly AiModuleDefinition[] = [
@@ -43,6 +44,16 @@ export const aiModules: readonly AiModuleDefinition[] = [
enabled: false,
Icon: Sigma,
},
{
id: 'robot',
title: 'Makelore Robot',
subtitle: 'AI 机器',
description: '制作你的第一个机器人小伙伴',
route: null,
enabled: false,
Icon: Bot,
switcherLabel: 'Robot 机器',
},
];
export function getAiModuleForPath(pathname: string): AiModuleId {

View File

@@ -4,49 +4,9 @@ export type SkillDisplayInfo = {
};
const SKILL_DISPLAY_BY_ID: Record<string, SkillDisplayInfo> = {
'youth-ai-product-course': {
name: '青少年人工智能产品课程',
description: '统一学生沟通方式、课程流程、项目产物与角色协作规则。',
},
'pm-project-plan': {
name: '通用软件项目规划',
description: '把软件想法整理成目标、用户、范围、里程碑、任务和验收标准。',
},
'product-demo-prototype': {
name: '产品原型设计',
description: '设计页面流程、关键交互、异常状态和可演示的产品原型。',
},
'designer-design-spec': {
name: '视觉设计规范',
description: '定义颜色、字体、布局、组件、响应式和无障碍规则。',
},
'dev-build-test': {
name: '开发与测试',
description: '实现代码、调试功能、设计数据接口,并用测试验证真实结果。',
},
'marketing-launch-story': {
name: '宣传与作品展示',
description: '编写真实易懂的项目介绍、演讲稿、宣传页和发布故事。',
},
'nianxxgame-skill': {
name: '网页游戏开发',
description: '完成游戏策划、开发、试玩、体验打磨和发布验证。',
},
'game-assets': {
name: '游戏素材生成',
description: '通过项目内固定版 Meowa 适配器生成、下载游戏素材,并记录来源、任务和授权状态。',
},
'partner-agent-showcase': {
name: '伙伴作品讲解',
description: '根据项目真实成果介绍作品、回答访客问题并帮助学生排练演示。',
},
'ui-ux-course-quality': {
name: '界面体验质检',
description: '检查可读性、操作反馈、移动端适配、视觉层级和无障碍体验。',
},
'youth-plain-language': {
name: '青少年通俗表达',
description: '让所有伙伴用简体中文、短句和少术语的方式,向 10 至 16 岁青少年解释任务。',
'agent-browser': {
name: '开发浏览器',
description: '打开、查看或调试本地及公网网页,读取 Console、Network、DOM 和样式信息。',
},
};

148
src/lib/user-avatar.ts Normal file
View File

@@ -0,0 +1,148 @@
import { browserImageCodec } from './browser-image-codec';
export const USER_AVATAR_MAX_DIMENSION = 512;
export const USER_AVATAR_MAX_SOURCE_BYTES = 32 * 1024 * 1024;
export const USER_AVATAR_SUPPORTED_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/webp',
] as const;
export type UserAvatarMimeType = typeof USER_AVATAR_SUPPORTED_MIME_TYPES[number];
export type PreparedUserAvatar = {
fileName: string;
mimeType: UserAvatarMimeType;
dataBase64: string;
previewUrl: string;
width: number;
height: number;
bytes: number;
};
function isSupportedMimeType(value: string): value is UserAvatarMimeType {
return (USER_AVATAR_SUPPORTED_MIME_TYPES as readonly string[]).includes(value);
}
function fileExtension(mimeType: UserAvatarMimeType): string {
if (mimeType === 'image/jpeg') return 'jpg';
if (mimeType === 'image/png') return 'png';
return 'webp';
}
function bytesToBase64(bytes: Uint8Array): string {
let result = '';
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
result += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return btoa(result);
}
async function blobToDataUrl(blob: Blob, mimeType: UserAvatarMimeType): Promise<{
dataBase64: string;
previewUrl: string;
}> {
const dataBase64 = bytesToBase64(new Uint8Array(await blob.arrayBuffer()));
return {
dataBase64,
previewUrl: `data:${mimeType};base64,${dataBase64}`,
};
}
function encodeCanvas(
canvas: HTMLCanvasElement,
mimeType: UserAvatarMimeType,
quality?: number,
): Promise<Blob | null> {
return new Promise((resolve, reject) => {
try {
canvas.toBlob(resolve, mimeType, quality);
} catch (error) {
reject(error);
}
});
}
async function encodeAvatarCanvas(canvas: HTMLCanvasElement): Promise<{
blob: Blob;
mimeType: UserAvatarMimeType;
}> {
const webp = await encodeCanvas(canvas, 'image/webp', 0.9);
if (webp?.type.trim().toLowerCase() === 'image/webp') {
return {
blob: webp,
mimeType: 'image/webp',
};
}
const png = await encodeCanvas(canvas, 'image/png');
if (png) {
return {
blob: png,
mimeType: 'image/png',
};
}
throw new Error('头像图片编码失败');
}
export async function prepareUserAvatar(file: File): Promise<PreparedUserAvatar> {
const mimeType = file.type.trim().toLowerCase();
if (!isSupportedMimeType(mimeType)) {
throw new Error('头像仅支持 PNG、JPEG 或 WebP 图片');
}
if (file.size > USER_AVATAR_MAX_SOURCE_BYTES) {
throw new Error('头像图片不能超过 32 MB');
}
const frameCount = await browserImageCodec.inspectFrameCount(file, mimeType);
if (frameCount !== 1) {
throw new Error(frameCount && frameCount > 1 ? '头像不支持动态图片' : '无法确认头像为静态图片');
}
const decoded = await browserImageCodec.decode(file, {
imageOrientation: 'from-image',
});
const canvas = document.createElement('canvas');
const side = Math.min(decoded.width, decoded.height);
const targetSize = Math.max(1, Math.min(USER_AVATAR_MAX_DIMENSION, Math.floor(side)));
const sourceX = Math.max(0, (decoded.width - side) / 2);
const sourceY = Math.max(0, (decoded.height - side) / 2);
canvas.width = targetSize;
canvas.height = targetSize;
try {
const context = canvas.getContext('2d');
if (!context) throw new Error('头像图片处理失败');
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.clearRect(0, 0, targetSize, targetSize);
context.drawImage(
decoded.source,
sourceX,
sourceY,
side,
side,
0,
0,
targetSize,
targetSize,
);
const encoded = await encodeAvatarCanvas(canvas);
const encodedData = await blobToDataUrl(encoded.blob, encoded.mimeType);
return {
fileName: `avatar.${fileExtension(encoded.mimeType)}`,
mimeType: encoded.mimeType,
...encodedData,
width: targetSize,
height: targetSize,
bytes: encoded.blob.size,
};
} finally {
decoded.dispose();
canvas.width = 1;
canvas.height = 1;
}
}