merge: integrate upstream main with local Makelore changes

This commit is contained in:
inman
2026-08-31 10:55:48 +08:00
128 changed files with 8278 additions and 3030 deletions

View File

@@ -76,15 +76,15 @@ async function encodeAvatarCanvas(canvas: HTMLCanvasElement): Promise<{
export async function prepareAgentAvatar(file: File): Promise<PreparedAgentAvatar> {
const mimeType = file.type.trim().toLowerCase();
if (!isSupportedMimeType(mimeType)) {
throw new Error('伙伴头像仅支持 PNG、JPEG 或 WebP 图片');
throw new Error('智能体头像仅支持 PNG、JPEG 或 WebP 图片');
}
if (file.size > AGENT_AVATAR_MAX_SOURCE_BYTES) {
throw new Error('伙伴头像图片不能超过 32 MB');
throw new Error('智能体头像图片不能超过 32 MB');
}
const frameCount = await browserImageCodec.inspectFrameCount(file, mimeType);
if (frameCount !== 1) {
throw new Error(frameCount && frameCount > 1 ? '伙伴头像不支持动态图片' : '无法确认伙伴头像为静态图片');
throw new Error(frameCount && frameCount > 1 ? '智能体头像不支持动态图片' : '无法确认智能体头像为静态图片');
}
const decoded = await browserImageCodec.decode(file, {
@@ -100,7 +100,7 @@ export async function prepareAgentAvatar(file: File): Promise<PreparedAgentAvata
try {
const context = canvas.getContext('2d');
if (!context) throw new Error('伙伴头像图片处理失败');
if (!context) throw new Error('智能体头像图片处理失败');
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.clearRect(0, 0, targetSize, targetSize);

View File

@@ -86,16 +86,6 @@ export async function removeCodingProject(projectId: string): Promise<void> {
});
}
export async function acknowledgeLegacyCodingConversationNotice(
projectId: string,
): Promise<CodingProjectConfigSnapshot> {
const response = await hostApiFetch<{ snapshot: CodingProjectConfigSnapshot }>(
'/api/coding/projects/legacy-conversation-notice/acknowledge',
{ method: 'POST', body: JSON.stringify({ projectId }) },
);
return response.snapshot;
}
export async function getCodingProjectConfig(
projectId: string,
): Promise<CodingProjectConfigSnapshot> {

View File

@@ -0,0 +1,140 @@
import { invokeIpc } from './api-client';
const MAX_LINK_LENGTH = 4_096;
const LOCAL_WEB_FILE_PATTERN = /\.(?:html?|xhtml)$/i;
function isBoundedSingleLine(value: string): boolean {
return value.length > 0
&& value.length <= MAX_LINK_LENGTH
&& !/[\0\r\n]/.test(value);
}
export function safeConversationExternalUrl(value: string | undefined): string | null {
const candidate = value?.trim() ?? '';
if (!isBoundedSingleLine(candidate)) return null;
try {
const url = new URL(candidate);
if ((url.protocol !== 'https:' && url.protocol !== 'http:')
|| url.username
|| url.password) {
return null;
}
return url.toString();
} catch {
return null;
}
}
export function revealableConversationPath(value: string): string | null {
const candidate = value.trim();
if (!isBoundedSingleLine(candidate)) return null;
const homeRelative = candidate === '~' || /^~[\\/]/.test(candidate);
const posixAbsolute = candidate.startsWith('/');
const windowsAbsolute = /^[A-Za-z]:[\\/]/.test(candidate);
const windowsNetwork = /^\\\\[^\\]+\\[^\\]+/.test(candidate);
return homeRelative || posixAbsolute || windowsAbsolute || windowsNetwork
? candidate
: null;
}
export function openableConversationLocalWebPath(value: string): string | null {
const path = revealableConversationPath(value);
return path && LOCAL_WEB_FILE_PATTERN.test(path) ? path : null;
}
export function previewableConversationRelativePath(value: string): string | null {
const candidate = value.trim();
if (!isBoundedSingleLine(candidate)
|| revealableConversationPath(candidate)
|| /^[A-Za-z][A-Za-z0-9+.-]*:/.test(candidate)) {
return null;
}
const normalized = candidate.replaceAll('\\', '/').replace(/^\.\//, '');
const segments = normalized.split('/');
if (!normalized
|| normalized.startsWith('/')
|| segments.some((segment) => !segment || segment === '.' || segment === '..')
|| !LOCAL_WEB_FILE_PATTERN.test(normalized)) {
return null;
}
return normalized;
}
function comparableLocalPath(value: string): string {
return value.replaceAll('\\', '/').replace(/\/$/, '');
}
export function resolveConversationMarkdownWebPath(
value: string,
markdown: string,
): string | null {
const relativePath = previewableConversationRelativePath(value);
if (!relativePath) return null;
const matches = new Set<string>();
for (const match of markdown.matchAll(/`([^`\r\n]+)`/g)) {
const absolutePath = revealableConversationPath(match[1] ?? '');
if (!absolutePath || !LOCAL_WEB_FILE_PATTERN.test(absolutePath)) continue;
const comparable = comparableLocalPath(absolutePath);
if (comparable.endsWith(`/${relativePath}`)) matches.add(absolutePath);
}
return matches.size === 1 ? [...matches][0] ?? null : null;
}
function resolveHomeRelativePath(value: string, home: string): string {
if (value === '~') return home;
const separator = home.includes('\\') ? '\\' : '/';
const suffix = value.slice(2).replace(/[\\/]/g, separator);
return `${home.replace(/[\\/]$/, '')}${separator}${suffix}`;
}
export async function openConversationExternalUrl(value: string): Promise<void> {
const url = safeConversationExternalUrl(value);
if (!url) throw new Error('Unsupported external URL');
await invokeIpc('shell:openExternal', url);
}
export async function revealConversationPath(value: string): Promise<void> {
const path = revealableConversationPath(value);
if (!path) throw new Error('Unsupported local path');
let resolvedPath = path;
if (path === '~' || /^~[\\/]/.test(path)) {
const home = await invokeIpc<string>('app:getPath', 'home');
resolvedPath = resolveHomeRelativePath(path, home);
}
await invokeIpc('shell:showItemInFolder', resolvedPath);
}
export async function openConversationLocalWebFile(value: string): Promise<void> {
const path = openableConversationLocalWebPath(value);
if (!path) throw new Error('Unsupported local web file');
let resolvedPath = path;
if (path === '~' || /^~[\\/]/.test(path)) {
const home = await invokeIpc<string>('app:getPath', 'home');
resolvedPath = resolveHomeRelativePath(path, home);
}
const error = await invokeIpc<string>('shell:openPath', resolvedPath);
if (error) throw new Error('Local web file could not be opened');
}
export async function showConversationLinkContextMenu(
request:
| { kind: 'external'; target: string }
| { kind: 'local-web'; target: string },
): Promise<void> {
if (request.kind === 'external') {
const target = safeConversationExternalUrl(request.target);
if (!target) throw new Error('Unsupported external URL');
await invokeIpc('shell:showLinkContextMenu', { kind: request.kind, target });
return;
}
const target = openableConversationLocalWebPath(request.target);
if (!target) throw new Error('Unsupported local web file');
await invokeIpc('shell:showLinkContextMenu', { kind: request.kind, target });
}

View File

@@ -29,6 +29,6 @@ const SKILL_DISPLAY_BY_ID: Record<string, SkillDisplayInfo> = {
export function getSkillDisplayInfo(skillId: string): SkillDisplayInfo {
return SKILL_DISPLAY_BY_ID[skillId] ?? {
name: '自定义技能',
description: '用于扩展伙伴能力的自定义工具。',
description: '用于扩展智能体工作能力的自定义技能。',
};
}

View File

@@ -0,0 +1,90 @@
const VOICE_WAV_SAMPLE_RATE = 16_000;
const VOICE_WAV_CHANNELS = 1;
const VOICE_WAV_BITS_PER_SAMPLE = 16;
type OfflineAudioContextConstructor = new (
numberOfChannels: number,
length: number,
sampleRate: number,
) => OfflineAudioContext;
type AudioContextWindow = Window & {
webkitAudioContext?: typeof AudioContext;
webkitOfflineAudioContext?: OfflineAudioContextConstructor;
};
export async function blobToBase64(blob: Blob): Promise<string> {
const bytes = new Uint8Array(await blob.arrayBuffer());
let binary = '';
const chunkSize = 0x8000;
for (let index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
}
return btoa(binary);
}
function writeAscii(view: DataView, offset: number, value: string): void {
for (let index = 0; index < value.length; index += 1) {
view.setUint8(offset + index, value.charCodeAt(index));
}
}
function audioBufferTo16BitPcmWav(audioBuffer: AudioBuffer): Blob {
const samples = audioBuffer.getChannelData(0);
const bytesPerSample = VOICE_WAV_BITS_PER_SAMPLE / 8;
const blockAlign = VOICE_WAV_CHANNELS * bytesPerSample;
const dataSize = samples.length * blockAlign;
const wavBuffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(wavBuffer);
writeAscii(view, 0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeAscii(view, 8, 'WAVE');
writeAscii(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, VOICE_WAV_CHANNELS, true);
view.setUint32(24, VOICE_WAV_SAMPLE_RATE, true);
view.setUint32(28, VOICE_WAV_SAMPLE_RATE * blockAlign, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, VOICE_WAV_BITS_PER_SAMPLE, true);
writeAscii(view, 36, 'data');
view.setUint32(40, dataSize, true);
let offset = 44;
for (const sample of samples) {
const clamped = Math.max(-1, Math.min(1, sample));
view.setInt16(offset, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
offset += bytesPerSample;
}
return new Blob([new Uint8Array(wavBuffer)], { type: 'audio/wav' });
}
export async function convertAudioBlobTo16kMonoWav(audioBlob: Blob): Promise<Blob> {
const audioWindow = window as AudioContextWindow;
const AudioContextCtor = window.AudioContext ?? audioWindow.webkitAudioContext;
const OfflineAudioContextCtor = window.OfflineAudioContext
?? audioWindow.webkitOfflineAudioContext;
if (!AudioContextCtor || !OfflineAudioContextCtor) {
throw new Error('当前环境无法转换录音');
}
const audioContext = new AudioContextCtor();
let decodedBuffer: AudioBuffer;
try {
decodedBuffer = await audioContext.decodeAudioData(await audioBlob.arrayBuffer());
} finally {
await audioContext.close().catch(() => undefined);
}
const frameCount = Math.max(1, Math.ceil(decodedBuffer.duration * VOICE_WAV_SAMPLE_RATE));
const offlineContext = new OfflineAudioContextCtor(
VOICE_WAV_CHANNELS,
frameCount,
VOICE_WAV_SAMPLE_RATE,
);
const source = offlineContext.createBufferSource();
source.buffer = decodedBuffer;
source.connect(offlineContext.destination);
source.start(0);
return audioBufferTo16BitPcmWav(await offlineContext.startRendering());
}