feat: TypeScript + Electron v2 重构 - 纯桌面版
- 全面迁移到 TypeScript,严格类型定义 - 放弃 Web 版,专注 Electron 桌面应用 - 主进程模块化:main.ts, preload.ts, menu.ts, tray.ts, ipc.ts, utils.ts - 渲染进程完整迁移所有功能组件 - 删除 PWA 相关文件 (sw.js, manifest.json) - 删除 Web 版降级逻辑 - 保留所有核心功能:流式对话、多模型、Think推理、多模态、RAG知识库、Agent预设、历史管理 - 保留 Windows 11 Fluent Design 暗色主题样式
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Markdown 渲染器配置
|
||||
*/
|
||||
|
||||
import { SAFE_URI_SCHEMES } from './sanitizer.js';
|
||||
|
||||
// 简化的 Markdown 解析器(内联,不依赖外部 marked 库)
|
||||
// 支持基本 GFM 语法:标题、粗体、斜体、代码块、链接、列表、表格
|
||||
|
||||
function parseMarkdown(md: string): string {
|
||||
if (!md) return '';
|
||||
let html = escapeForMd(md);
|
||||
|
||||
// 代码块 ```...```
|
||||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, lang, code) => {
|
||||
return `<pre><code class="language-${lang}">${code.trim()}</code></pre>`;
|
||||
});
|
||||
|
||||
// 行内代码
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
|
||||
// 标题
|
||||
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>');
|
||||
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>');
|
||||
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>');
|
||||
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>');
|
||||
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>');
|
||||
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>');
|
||||
|
||||
// 粗体+斜体
|
||||
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
||||
// 粗体
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
// 斜体
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
// 删除线
|
||||
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
||||
|
||||
// 链接 [text](url)
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, text, href) => {
|
||||
try {
|
||||
const safe = SAFE_URI_SCHEMES.has(new URL(href, 'https://placeholder').protocol);
|
||||
if (!safe) return `<span class="blocked-link">${text}</span>`;
|
||||
} catch { /* allow relative */ }
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
||||
});
|
||||
|
||||
// 图片 
|
||||
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" loading="lazy">');
|
||||
|
||||
// 引用块
|
||||
html = html.replace(/^>\s+(.+)$/gm, '<blockquote><p>$1</p></blockquote>');
|
||||
|
||||
// 水平线
|
||||
html = html.replace(/^---+$/gm, '<hr>');
|
||||
|
||||
// 无序列表
|
||||
html = html.replace(/^[\-\*]\s+(.+)$/gm, '<li>$1</li>');
|
||||
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
|
||||
|
||||
// 有序列表
|
||||
html = html.replace(/^\d+\.\s+(.+)$/gm, '<li>$1</li>');
|
||||
|
||||
// 换行
|
||||
html = html.replace(/\n\n/g, '</p><p>');
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
|
||||
// 包裹在 p 标签中
|
||||
if (!html.startsWith('<')) {
|
||||
html = '<p>' + html + '</p>';
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function escapeForMd(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export { parseMarkdown as marked };
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Sanitizer - HTML 净化器
|
||||
*/
|
||||
|
||||
export const SAFE_URI_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:', 'ftp:', 'ftps:', 'xmpp:']);
|
||||
export const SAFE_DATA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']);
|
||||
|
||||
const ALLOWED_TAGS = new Set([
|
||||
'a', 'abbr', 'b', 'blockquote', 'br', 'code', 'del', 'details', 'div',
|
||||
'dl', 'dt', 'dd', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr',
|
||||
'i', 'img', 'ins', 'kbd', 'li', 'ol', 'p', 'pre', 'q', 'rp', 'rt',
|
||||
'ruby', 's', 'samp', 'small', 'span', 'strike', 'strong', 'sub',
|
||||
'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead',
|
||||
'tr', 'u', 'ul', 'var', 'input',
|
||||
]);
|
||||
|
||||
const ALLOWED_ATTRS = new Set([
|
||||
'href', 'src', 'alt', 'title', 'width', 'height', 'align',
|
||||
'target', 'rel', 'class', 'id', 'type', 'checked', 'disabled',
|
||||
'start', 'value', 'scope', 'rowspan', 'colspan', 'lang',
|
||||
]);
|
||||
|
||||
const DANGEROUS_PREFIXES = ['on', 'xlink:href', 'xmlns:'];
|
||||
|
||||
export function sanitize(html: string): string {
|
||||
try {
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = html;
|
||||
const root = template.content;
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
|
||||
const comments: Comment[] = [];
|
||||
while (walker.nextNode()) comments.push(walker.currentNode as Comment);
|
||||
comments.forEach(c => c.remove());
|
||||
|
||||
const elements = [...root.querySelectorAll('*')];
|
||||
for (const el of elements) {
|
||||
if (!el.parentNode) continue;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
|
||||
if (!ALLOWED_TAGS.has(tag)) {
|
||||
while (el.firstChild) el.parentNode.insertBefore(el.firstChild, el);
|
||||
el.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const attr of [...el.attributes]) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (DANGEROUS_PREFIXES.some(p => name.startsWith(p))) {
|
||||
el.removeAttribute(attr.name);
|
||||
continue;
|
||||
}
|
||||
if (!ALLOWED_ATTRS.has(name)) {
|
||||
el.removeAttribute(attr.name);
|
||||
continue;
|
||||
}
|
||||
if ((name === 'href' || name === 'src' || name === 'action') && attr.value) {
|
||||
const val = attr.value.trim().toLowerCase();
|
||||
if (/^(javascript|vbscript|data|file):/i.test(val)) {
|
||||
const mimeMatch = val.match(/^data:([^;,]+)/i);
|
||||
if (!mimeMatch || !SAFE_DATA_TYPES.has(mimeMatch[1].toLowerCase())) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return root.innerHTML;
|
||||
} catch (e) {
|
||||
console.warn('[Sanitizer] 净化失败:', e);
|
||||
return html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Utils - 通用工具函数
|
||||
*/
|
||||
|
||||
/** 生成唯一 ID */
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/** 格式化时间戳 */
|
||||
export function formatTime(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number): string => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 截断文本 */
|
||||
export function truncate(str: string, len = 50): string {
|
||||
if (!str) return '';
|
||||
return str.length > len ? str.substring(0, len) + '...' : str;
|
||||
}
|
||||
|
||||
/** 防抖 */
|
||||
export function debounce<T extends (...args: unknown[]) => unknown>(fn: T, ms = 300): (...args: Parameters<T>) => void {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
/** 格式化字节大小 */
|
||||
export function formatSize(bytes: number): string {
|
||||
if (!bytes) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
let size = bytes;
|
||||
while (size >= 1024 && i < units.length - 1) {
|
||||
size /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${size.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** HTML 转义 */
|
||||
export function escapeHtml(str: string): string {
|
||||
const map: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
return String(str).replace(/[&<>"']/g, (c) => map[c]);
|
||||
}
|
||||
|
||||
/** 下载文件 */
|
||||
export function downloadFile(filename: string, content: string, type: string): void {
|
||||
const blob = new Blob([content], { type: type + ';charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** File → Base64(去前缀) */
|
||||
export function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(',')[1];
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** File → 文本内容 */
|
||||
export function fileToText(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** 根据文件名检测语言标识(用于 Markdown 代码块) */
|
||||
export function detectLanguage(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
const map: Record<string, string> = {
|
||||
py: 'python', pyw: 'python', pyi: 'python',
|
||||
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
||||
ts: 'typescript', tsx: 'tsx', jsx: 'jsx',
|
||||
java: 'java', class: 'java',
|
||||
c: 'c', h: 'c',
|
||||
cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp',
|
||||
go: 'go', rs: 'rust', rb: 'ruby', php: 'php',
|
||||
sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
|
||||
sql: 'sql', html: 'html', htm: 'html', css: 'css',
|
||||
json: 'json', jsonl: 'json',
|
||||
xml: 'xml', svg: 'svg',
|
||||
yaml: 'yaml', yml: 'yaml', toml: 'toml',
|
||||
ini: 'ini', cfg: 'ini', conf: 'ini',
|
||||
md: 'markdown', markdown: 'markdown', rst: 'rst',
|
||||
txt: '', csv: 'csv', tsv: 'csv', log: '',
|
||||
swift: 'swift', kt: 'kotlin', kts: 'kotlin',
|
||||
scala: 'scala', lua: 'lua', pl: 'perl', r: 'r',
|
||||
m: 'matlab', mm: 'objectivec',
|
||||
cs: 'csharp', fs: 'fsharp', vb: 'vbnet',
|
||||
ex: 'elixir', exs: 'elixir', erl: 'erlang',
|
||||
hs: 'haskell', clj: 'clojure', lisp: 'lisp',
|
||||
dockerfile: 'dockerfile', makefile: 'makefile',
|
||||
diff: 'diff', patch: 'diff',
|
||||
};
|
||||
return map[ext] ?? ext;
|
||||
}
|
||||
Reference in New Issue
Block a user