merge: integrate upstream main with local Makelore changes
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
140
src/lib/conversation-links.ts
Normal file
140
src/lib/conversation-links.ts
Normal 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 });
|
||||
}
|
||||
@@ -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: '用于扩展智能体工作能力的自定义技能。',
|
||||
};
|
||||
}
|
||||
|
||||
90
src/lib/voice-recording.ts
Normal file
90
src/lib/voice-recording.ts
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user