feat: integrate learning module
This commit is contained in:
@@ -40,7 +40,7 @@ export const aiModules: readonly AiModuleDefinition[] = [
|
||||
title: 'Makelore Learning',
|
||||
subtitle: 'AI 学习',
|
||||
description: '用顶尖的方法解锁万物规律',
|
||||
route: null,
|
||||
route: '/learning',
|
||||
enabled: true,
|
||||
Icon: Sigma,
|
||||
},
|
||||
@@ -66,5 +66,8 @@ export function getAiModuleForPath(pathname: string): AiModuleId {
|
||||
if (pathname === '/ai-hardware' || pathname.startsWith('/ai-hardware/')) {
|
||||
return 'robot';
|
||||
}
|
||||
if (pathname === '/learning' || pathname.startsWith('/learning/')) {
|
||||
return 'learning';
|
||||
}
|
||||
return 'programming';
|
||||
}
|
||||
|
||||
19
src/lib/learning-runtime-anchor.ts
Normal file
19
src/lib/learning-runtime-anchor.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type LearningRuntimeAnchor = {
|
||||
sceneId?: string;
|
||||
sceneOrder?: number;
|
||||
};
|
||||
|
||||
/** Copy only the scene identity accepted by the Works runtime boundary. */
|
||||
export function sanitizeLearningRuntimeAnchor(value: unknown): LearningRuntimeAnchor | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const anchor = {
|
||||
...(typeof candidate.sceneId === 'string' && candidate.sceneId.length > 0 && candidate.sceneId.length <= 256
|
||||
? { sceneId: candidate.sceneId }
|
||||
: {}),
|
||||
...(Number.isSafeInteger(candidate.sceneOrder) && Number(candidate.sceneOrder) >= 0
|
||||
? { sceneOrder: Number(candidate.sceneOrder) }
|
||||
: {}),
|
||||
};
|
||||
return Object.keys(anchor).length > 0 ? anchor : undefined;
|
||||
}
|
||||
213
src/lib/learning.ts
Normal file
213
src/lib/learning.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { AppError } from '@/lib/error-model';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import {
|
||||
LEARNING_API_PATH,
|
||||
type LearningCourse,
|
||||
type InstalledLearningCourse,
|
||||
type LearningProgress,
|
||||
type LearningProgressWrite,
|
||||
type LearningGeneration,
|
||||
type LearningGenerationMaterialUpload,
|
||||
type LearningGenerationOptions,
|
||||
type LearningGenerationUploadRequest,
|
||||
type LearningClassroomPayload,
|
||||
type LearningRuntimeBridgeEvent,
|
||||
type LearningRuntimeBridgeRequest,
|
||||
} from '../../shared/learning';
|
||||
|
||||
type Envelope<T> = {
|
||||
success?: boolean;
|
||||
status?: number;
|
||||
code?: string;
|
||||
error?: string;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
export class LearningApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'LearningApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let envelope: Envelope<T>;
|
||||
try {
|
||||
envelope = await hostApiFetch<Envelope<T>>(path, init);
|
||||
} catch (error) {
|
||||
const status = error instanceof AppError && typeof error.details?.status === 'number'
|
||||
? error.details.status
|
||||
: 502;
|
||||
throw new LearningApiError(status, 'LEARNING_REQUEST_FAILED', error instanceof Error ? error.message : '学习服务请求失败');
|
||||
}
|
||||
if (!envelope.success || envelope.data === undefined) {
|
||||
throw new LearningApiError(
|
||||
envelope.status ?? 502,
|
||||
envelope.code ?? 'LEARNING_REQUEST_FAILED',
|
||||
envelope.error ?? '学习服务请求失败',
|
||||
);
|
||||
}
|
||||
return envelope.data;
|
||||
}
|
||||
|
||||
export function fetchLearningCourses(): Promise<LearningCourse[]> {
|
||||
return request(`${LEARNING_API_PATH}/courses`);
|
||||
}
|
||||
|
||||
export function fetchMyLearningCourses(): Promise<LearningCourse[]> {
|
||||
return request(`${LEARNING_API_PATH}/courses/mine`);
|
||||
}
|
||||
|
||||
export function fetchLearningProgress(): Promise<LearningProgress[]> {
|
||||
return request(`${LEARNING_API_PATH}/progress`);
|
||||
}
|
||||
|
||||
export const DEFAULT_LEARNING_GENERATION_OPTIONS: Omit<LearningGenerationOptions, 'requirement'> = {
|
||||
enableWebSearch: false,
|
||||
enableImageGeneration: false,
|
||||
enableVideoGeneration: false,
|
||||
enableTTS: true,
|
||||
interactiveMode: true,
|
||||
taskEngineMode: false,
|
||||
};
|
||||
|
||||
function normalizeGenerationOptions(
|
||||
input: string | LearningGenerationOptions,
|
||||
): LearningGenerationOptions {
|
||||
return typeof input === 'string'
|
||||
? { requirement: input, ...DEFAULT_LEARNING_GENERATION_OPTIONS }
|
||||
: input;
|
||||
}
|
||||
|
||||
export function startLearningGeneration(
|
||||
input: string | LearningGenerationOptions,
|
||||
materials: LearningGenerationMaterialUpload[] = [],
|
||||
): Promise<LearningGeneration> {
|
||||
const options = normalizeGenerationOptions(input);
|
||||
if (materials.length > 0) {
|
||||
const request: LearningGenerationUploadRequest = { options, materials };
|
||||
return invokeIpc<LearningGeneration>('learning:startGeneration', request);
|
||||
}
|
||||
return request(`${LEARNING_API_PATH}/generations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchLearningGeneration(jobId: string): Promise<LearningGeneration> {
|
||||
return request(`${LEARNING_API_PATH}/generations/${encodeURIComponent(jobId)}`);
|
||||
}
|
||||
|
||||
export function cancelLearningGeneration(jobId: string): Promise<LearningGeneration> {
|
||||
return request(`${LEARNING_API_PATH}/generations/${encodeURIComponent(jobId)}/cancel`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function resumeLearningGeneration(jobId: string): Promise<LearningGeneration> {
|
||||
return request(`${LEARNING_API_PATH}/generations/${encodeURIComponent(jobId)}/resume`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function finalizeLearningGeneration(jobId: string): Promise<LearningCourse> {
|
||||
return request(`${LEARNING_API_PATH}/generations/${encodeURIComponent(jobId)}/finalize`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export const LEARNING_LIBRARY_CHANGED_EVENT = 'makelore:learning-library-changed';
|
||||
|
||||
export function saveLearningProgress(
|
||||
courseId: string,
|
||||
progress: LearningProgressWrite,
|
||||
): Promise<LearningProgress> {
|
||||
const payload: LearningProgressWrite = {
|
||||
contentHash: progress.contentHash,
|
||||
...(progress.moduleId ? { moduleId: progress.moduleId } : {}),
|
||||
sceneOrder: progress.sceneOrder,
|
||||
actionIndex: progress.actionIndex,
|
||||
positionMs: progress.positionMs,
|
||||
completed: progress.completed,
|
||||
};
|
||||
return request(`${LEARNING_API_PATH}/courses/${encodeURIComponent(courseId)}/progress`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadLearningCourse(courseId: string): Promise<InstalledLearningCourse> {
|
||||
const record = await invokeIpc<InstalledLearningCourse>('learning:download', courseId);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event(LEARNING_LIBRARY_CHANGED_EVENT));
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
export function listInstalledLearningCourses(): Promise<InstalledLearningCourse[]> {
|
||||
return invokeIpc<InstalledLearningCourse[]>('learning:listInstalled');
|
||||
}
|
||||
|
||||
export type { LearningClassroomPayload } from '../../shared/learning';
|
||||
|
||||
export function readLearningClassroom(courseId: string, moduleId?: string): Promise<LearningClassroomPayload> {
|
||||
return invokeIpc<LearningClassroomPayload>('learning:readClassroom', courseId, moduleId);
|
||||
}
|
||||
|
||||
export function getLearningPlayerUrl(): Promise<string> {
|
||||
return invokeIpc<string>('learning:playerUrl');
|
||||
}
|
||||
|
||||
export type LearningAgentRequest = {
|
||||
courseId: string;
|
||||
contentHash: string;
|
||||
message: string;
|
||||
history?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
anchor?: {
|
||||
sceneId?: string;
|
||||
sceneOrder?: number;
|
||||
sceneTitle?: string;
|
||||
actionIndex?: number;
|
||||
moduleId?: string | null;
|
||||
moduleContentHash?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function askLearningAgent(request: LearningAgentRequest): Promise<{ text: string }> {
|
||||
return invokeIpc<{ text: string }>('learning:agentAsk', request);
|
||||
}
|
||||
|
||||
export function resetLearningAgent(courseId?: string, contentHash?: string): Promise<void> {
|
||||
return invokeIpc<void>('learning:agentReset', courseId, contentHash);
|
||||
}
|
||||
|
||||
export function transcribeLearningSpeech(request: {
|
||||
audio: Uint8Array;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
language?: string;
|
||||
}): Promise<{ text: string }> {
|
||||
return invokeIpc<{ text: string }>('learning:transcribe', request);
|
||||
}
|
||||
|
||||
export async function streamLearningRuntime(
|
||||
request: LearningRuntimeBridgeRequest,
|
||||
onEvent: (event: LearningRuntimeBridgeEvent) => void,
|
||||
): Promise<void> {
|
||||
const unsubscribe = window.electron.ipcRenderer.on('learning:runtime-event', (payload) => {
|
||||
const event = payload as LearningRuntimeBridgeEvent;
|
||||
if (event?.requestId === request.requestId) onEvent(event);
|
||||
});
|
||||
try {
|
||||
await invokeIpc<void>('learning:runtimeRequest', request);
|
||||
} finally {
|
||||
if (typeof unsubscribe === 'function') unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,14 @@ import type {
|
||||
|
||||
const EMPTY_ASSISTANT_RESPONSE_MESSAGE = '模型没有返回任何内容。请重试,或切换到支持图片输入的模型。';
|
||||
|
||||
function isCompactionPart(input: unknown): boolean {
|
||||
return getRecord(input)?.type === 'compaction';
|
||||
}
|
||||
|
||||
function hasCompactionPart(parts: unknown[]): boolean {
|
||||
return parts.some(isCompactionPart);
|
||||
}
|
||||
|
||||
function toMs(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value < 1e12 ? value * 1000 : value;
|
||||
@@ -278,6 +286,7 @@ function normalizeLegacyBlocks(content: unknown): ContentBlock[] {
|
||||
|
||||
function normalizeLegacyMessage(input: Record<string, unknown>): RawMessage {
|
||||
const contentBlocks = normalizeLegacyBlocks(input.content);
|
||||
const hasCompactionContent = Array.isArray(input.content) && hasCompactionPart(input.content);
|
||||
const topLevelReasoning = typeof input.reasoning === 'string'
|
||||
? input.reasoning
|
||||
: typeof input.reasoning_content === 'string'
|
||||
@@ -297,6 +306,17 @@ function normalizeLegacyMessage(input: Record<string, unknown>): RawMessage {
|
||||
};
|
||||
}
|
||||
|
||||
// A legacy compaction record can otherwise fall through to the JSON
|
||||
// fallback below, exposing the runtime's private summary in the transcript.
|
||||
if (hasCompactionContent) {
|
||||
return {
|
||||
id: typeof input.id === 'string' ? input.id : undefined,
|
||||
role: normalizeRole(input.role),
|
||||
timestamp: normalizeTimestamp(input),
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof input.content === 'string') {
|
||||
return {
|
||||
id: typeof input.id === 'string' ? input.id : undefined,
|
||||
@@ -619,6 +639,7 @@ function normalizeStructuredMessage(input: Record<string, unknown>): RawMessage
|
||||
if (!info || !parts) return null;
|
||||
const errorMessage = getErrorMessage(info.error ?? input.error ?? input.errorMessage ?? input.error_message);
|
||||
const role = normalizeRole(info.role);
|
||||
const hasCompaction = hasCompactionPart(parts);
|
||||
const commandHistory: CommandHistorySelection = role === 'user'
|
||||
? selectCommandHistoryParts(parts)
|
||||
: { parts };
|
||||
@@ -637,7 +658,10 @@ function normalizeStructuredMessage(input: Record<string, unknown>): RawMessage
|
||||
}
|
||||
|
||||
const finalErrorMessage = errorMessage
|
||||
?? (role === 'assistant' && blocks.length === 0 && hasCompletedAssistantSignal(info, input, parts)
|
||||
?? (!hasCompaction
|
||||
&& role === 'assistant'
|
||||
&& blocks.length === 0
|
||||
&& hasCompletedAssistantSignal(info, input, parts)
|
||||
? EMPTY_ASSISTANT_RESPONSE_MESSAGE
|
||||
: undefined);
|
||||
const runtimeContext = getRuntimeMessageContext(role, info);
|
||||
@@ -664,6 +688,11 @@ export function normalizeOpencodeSessionMessages(input: unknown): RawMessage[] {
|
||||
const structuredInfo = getRecord(record.info);
|
||||
const structuredParts = Array.isArray(record.parts) ? record.parts : null;
|
||||
const structuredLike = hasOwn(record, 'info') || hasOwn(record, 'parts');
|
||||
if (
|
||||
isCompactionPart(record)
|
||||
|| isCompactionPart(record.part)
|
||||
|| (structuredParts && hasCompactionPart(structuredParts) && !structuredInfo)
|
||||
) return [];
|
||||
if (structuredLike && (!structuredInfo || !structuredParts)) {
|
||||
return hasDirectCommandInvocationMarker(record.parts)
|
||||
|| hasLegacyCommandInvocationMarker(record)
|
||||
@@ -681,6 +710,15 @@ export function normalizeOpencodeSessionMessages(input: unknown): RawMessage[] {
|
||||
return [commandHistoryUnavailableMessage(record)];
|
||||
}
|
||||
const structured = normalizeStructuredMessage(record);
|
||||
if (
|
||||
structured
|
||||
&& hasCompactionPart(structuredParts)
|
||||
&& Array.isArray(structured.content)
|
||||
&& structured.content.length === 0
|
||||
&& !(structured._attachedFiles?.length)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return structured
|
||||
? [structured]
|
||||
: [commandHistoryUnavailableMessage(record)];
|
||||
@@ -688,7 +726,17 @@ export function normalizeOpencodeSessionMessages(input: unknown): RawMessage[] {
|
||||
if (hasLegacyCommandInvocationMarker(record)) {
|
||||
return [commandHistoryUnavailableMessage(record)];
|
||||
}
|
||||
return [normalizeLegacyMessage(record)];
|
||||
const legacy = normalizeLegacyMessage(record);
|
||||
if (
|
||||
Array.isArray(record.content)
|
||||
&& hasCompactionPart(record.content)
|
||||
&& Array.isArray(legacy.content)
|
||||
&& legacy.content.length === 0
|
||||
&& !(legacy._attachedFiles?.length)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [legacy];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -735,6 +783,7 @@ export function applyStreamingPartToMessage(message: RawMessage | null, input: u
|
||||
const record = getRecord(input);
|
||||
const part = getRecord(record?.part ?? input);
|
||||
if (!part) return message;
|
||||
if (isCompactionPart(part)) return removeStreamingPartFromMessage(message, input);
|
||||
|
||||
const current = message ?? {
|
||||
role: 'assistant',
|
||||
@@ -785,6 +834,7 @@ export function applyStreamingPartDeltaToMessage(message: RawMessage | null, inp
|
||||
if (!record) return message;
|
||||
|
||||
const part = getRecord(record.part) ?? record;
|
||||
if (isCompactionPart(part)) return removeStreamingPartFromMessage(message, input);
|
||||
const delta = getStreamingDelta(record, part);
|
||||
if (!delta) return message;
|
||||
|
||||
|
||||
@@ -12,17 +12,13 @@ const SKILL_DISPLAY_BY_ID: Record<string, SkillDisplayInfo> = {
|
||||
name: '项目演示',
|
||||
description: '把项目内容整理成可播放的 16:9 HTML 幻灯片,不生成 PPTX 或云端发布。',
|
||||
},
|
||||
'game-engine': {
|
||||
name: '游戏引擎',
|
||||
description: '构建基于 HTML5、Canvas、WebGL 和 JavaScript 的 2D/3D 游戏,处理循环、物理、碰撞、输入、音频与本地预览。',
|
||||
},
|
||||
grilling: {
|
||||
name: '方案质询',
|
||||
description: '在重要工作开始前逐项澄清目标、范围和关键取舍,确认后再执行。',
|
||||
},
|
||||
'planning-with-files': {
|
||||
name: '项目规划',
|
||||
description: '为复杂任务维护可恢复的计划、发现和进展,记录在项目的 .niancode/agent-planning/ 中。',
|
||||
description: '为复杂任务维护可恢复的计划、发现和进展,直接记录在当前项目根目录。',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export type ProjectPublic = {
|
||||
app_id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
description?: string | null;
|
||||
cover_url?: string | null;
|
||||
category?: string | null;
|
||||
age_band?: string | null;
|
||||
@@ -15,6 +16,7 @@ export type ProjectPublic = {
|
||||
/** @deprecated Use play_url. Kept for one client compatibility release. */
|
||||
runtime_url?: string | null;
|
||||
creator_name?: string | null;
|
||||
creator_age?: number | null;
|
||||
buddy_name?: string | null;
|
||||
buddy_sprite_url?: string | null;
|
||||
buddy_pose_url?: string | null;
|
||||
@@ -74,7 +76,10 @@ export type WorksProjectMetadataInput = {
|
||||
app_id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
description?: string | null;
|
||||
cover_url?: string | null;
|
||||
creator_name?: string | null;
|
||||
creator_age?: number | null;
|
||||
category?: string | null;
|
||||
age_band?: string | null;
|
||||
difficulty?: string | null;
|
||||
@@ -133,6 +138,13 @@ export type WorksStaticPackageSummary = {
|
||||
export type WorksProjectSourcePublishInput = {
|
||||
projectId: string;
|
||||
project: WorksProjectMetadataInput;
|
||||
cover?: WorksProjectCoverUpload;
|
||||
};
|
||||
|
||||
export type WorksProjectCoverUpload = {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
dataBase64: string;
|
||||
};
|
||||
|
||||
export type WorksSubmissionBindingWarning = {
|
||||
@@ -428,6 +440,7 @@ export async function publishWorksProjectSource(
|
||||
body: JSON.stringify({
|
||||
projectId: input.projectId,
|
||||
project: input.project,
|
||||
...(input.cover ? { cover: input.cover } : {}),
|
||||
}),
|
||||
});
|
||||
if (!response.success || !response.package || !response.upload) {
|
||||
|
||||
Reference in New Issue
Block a user