完善客户端模块与工作区能力
This commit is contained in:
133
src/lib/agent-avatar-upload.ts
Normal file
133
src/lib/agent-avatar-upload.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
380
src/lib/agent-session-sync.ts
Normal file
380
src/lib/agent-session-sync.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
148
src/lib/user-avatar.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user