🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s

A professional workspace for AI coding agents with:
- Real-time streaming chat with SSE
- Session management (fork, clone, tree navigation)
- MCP tools browser & metadata panel
- Skills browser (global, project, package sources)
- Voice input & STT provider configuration
- Integrated terminal & file explorer
- Model selector with capability metadata
- Keyboard-first UI with command palette
- 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi)
- Tauri 2 desktop app
- React 19 + TypeScript + Vite 8 + Tailwind CSS 4
- Pi Agent extension runtime

Originally forked from OpenCodeUI, now an independent project.
License: GPL-3.0-only
This commit is contained in:
Evandro committed 2026-06-01 00:50:06 -03:00
commit b3489e1ede
983 files changed
+158475

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { collectActiveDirectories } from './activeScope'
describe('collectActiveDirectories', () => {
it('includes saved project directories in the active scope', () => {
expect(
collectActiveDirectories({
routeDirectory: '/workspace/current',
currentDirectory: '/workspace/current',
paneDirectories: ['/workspace/split-pane'],
projectDirectories: ['/workspace/current', '/workspace/project-a', '/workspace/project-b'],
}),
).toEqual(['/workspace/current', '/workspace/split-pane', '/workspace/project-a', '/workspace/project-b'])
})
it('deduplicates directories across different path styles', () => {
expect(
collectActiveDirectories({
routeDirectory: 'E:/Dev/Repo',
currentDirectory: 'e:\\dev\\repo',
paneDirectories: ['E:/dev/other'],
projectDirectories: ['e:/DEV/repo', 'E:\\DEV\\OTHER', 'E:/dev/third'],
}),
).toEqual(['E:/Dev/Repo', 'E:/dev/other', 'E:/dev/third'])
})
})
+30
View File
@@ -0,0 +1,30 @@
import { isSameDirectory } from './directoryUtils'
interface CollectActiveDirectoriesOptions {
routeDirectory?: string
currentDirectory?: string
paneDirectories?: string[]
projectDirectories?: string[]
}
export function collectActiveDirectories({
routeDirectory,
currentDirectory,
paneDirectories = [],
projectDirectories = [],
}: CollectActiveDirectoriesOptions): string[] {
const directories: string[] = []
const pushDirectory = (directory?: string) => {
if (!directory) return
if (directories.some(existing => isSameDirectory(existing, directory))) return
directories.push(directory)
}
pushDirectory(routeDirectory)
pushDirectory(currentDirectory)
paneDirectories.forEach(pushDirectory)
projectDirectories.forEach(pushDirectory)
return directories
}
+140
View File
@@ -0,0 +1,140 @@
/**
* ANSI escape code 处理工具
*
* 支持两种模式:
* - stripAnsi: 纯净文本,去掉所有控制符
* - parseAnsi: 解析为带颜色信息的 segments,用于渲染彩色终端输出
*/
// ANSI escape sequence 正则
// 匹配 CSI (Control Sequence Introducer) 序列:ESC[ ... 终止符
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1b\[[0-9;]*m/g
/**
* 去掉所有 ANSI escape codes
*/
export function stripAnsi(text: string): string {
return text.replace(ANSI_RE, '')
}
// ============================================
// ANSI → colored segments
// ============================================
export interface AnsiSegment {
text: string
fg?: string
bold?: boolean
dim?: boolean
italic?: boolean
}
// 标准 ANSI 3/4-bit 颜色映射
const ANSI_COLORS: Record<number, string> = {
30: 'var(--ansi-black, #545454)',
31: 'var(--ansi-red, #cf4647)',
32: 'var(--ansi-green, #4ea24c)',
33: 'var(--ansi-yellow, #c4a500)',
34: 'var(--ansi-blue, #3d7ec7)',
35: 'var(--ansi-magenta, #b44e91)',
36: 'var(--ansi-cyan, #21a8a5)',
37: 'var(--ansi-white, #cccccc)',
// 高亮/明亮色
90: 'var(--ansi-bright-black, #767676)',
91: 'var(--ansi-bright-red, #f0706f)',
92: 'var(--ansi-bright-green, #7bc96f)',
93: 'var(--ansi-bright-yellow, #e3d94e)',
94: 'var(--ansi-bright-blue, #6cb5ed)',
95: 'var(--ansi-bright-magenta, #d07fd0)',
96: 'var(--ansi-bright-cyan, #5fd7d7)',
97: 'var(--ansi-bright-white, #eeeeee)',
}
interface AnsiState {
fg?: string
bold: boolean
dim: boolean
italic: boolean
}
/**
* 解析含 ANSI 控制符的文本为 segments
*/
export function parseAnsi(text: string): AnsiSegment[] {
const segments: AnsiSegment[] = []
const state: AnsiState = { bold: false, dim: false, italic: false }
let lastIndex = 0
ANSI_RE.lastIndex = 0
let match: RegExpExecArray | null
while ((match = ANSI_RE.exec(text)) !== null) {
// 控制符前面的文本
if (match.index > lastIndex) {
const chunk = text.slice(lastIndex, match.index)
if (chunk) {
segments.push({
text: chunk,
fg: state.fg,
bold: state.bold || undefined,
dim: state.dim || undefined,
italic: state.italic || undefined,
})
}
}
lastIndex = ANSI_RE.lastIndex
// 解析 SGR 参数
const params = match[0].slice(2, -1) // 去掉 ESC[ 和 m
const codes = params === '' ? [0] : params.split(';').map(Number)
for (const code of codes) {
if (code === 0) {
// Reset
state.fg = undefined
state.bold = false
state.dim = false
state.italic = false
} else if (code === 1) {
state.bold = true
} else if (code === 2) {
state.dim = true
} else if (code === 3) {
state.italic = true
} else if (code === 22) {
state.bold = false
state.dim = false
} else if (code === 23) {
state.italic = false
} else if (code === 39) {
state.fg = undefined
} else if (ANSI_COLORS[code]) {
state.fg = ANSI_COLORS[code]
}
}
}
// 剩余文本
if (lastIndex < text.length) {
const chunk = text.slice(lastIndex)
if (chunk) {
segments.push({
text: chunk,
fg: state.fg,
bold: state.bold || undefined,
dim: state.dim || undefined,
italic: state.italic || undefined,
})
}
}
return segments
}
/**
* 检测文本是否包含 ANSI escape codes
*/
export function hasAnsi(text: string): boolean {
return ANSI_RE.test(text)
}
+59
View File
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { copyTextToClipboard } from './clipboard'
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
const originalExecCommand = Object.getOwnPropertyDescriptor(document, 'execCommand')
function restoreProperty(target: object, key: PropertyKey, descriptor?: PropertyDescriptor) {
if (descriptor) {
Object.defineProperty(target, key, descriptor)
return
}
Reflect.deleteProperty(target, key)
}
describe('copyTextToClipboard', () => {
afterEach(() => {
restoreProperty(navigator, 'clipboard', originalClipboard)
restoreProperty(document, 'execCommand', originalExecCommand)
vi.restoreAllMocks()
})
it('uses Clipboard API when available', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
const execCommand = vi.fn().mockReturnValue(true)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: execCommand,
})
await copyTextToClipboard('hello world')
expect(writeText).toHaveBeenCalledWith('hello world')
expect(execCommand).not.toHaveBeenCalled()
})
it('falls back to execCommand when Clipboard API fails', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('NotAllowedError'))
const execCommand = vi.fn().mockReturnValue(true)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: execCommand,
})
await copyTextToClipboard('fallback text')
expect(writeText).toHaveBeenCalledWith('fallback text')
expect(execCommand).toHaveBeenCalledWith('copy')
})
})
+65
View File
@@ -0,0 +1,65 @@
function fallbackCopyText(text: string): boolean {
if (typeof document === 'undefined' || !document.body) {
return false
}
const textArea = document.createElement('textarea')
textArea.value = text
textArea.setAttribute('readonly', '')
textArea.setAttribute('aria-hidden', 'true')
textArea.style.position = 'fixed'
textArea.style.top = '0'
textArea.style.left = '-9999px'
textArea.style.opacity = '0'
textArea.style.pointerEvents = 'none'
const selection = document.getSelection()
const savedRanges = selection
? Array.from({ length: selection.rangeCount }, (_, index) => selection.getRangeAt(index).cloneRange())
: []
const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
textArea.setSelectionRange(0, text.length)
const copied = typeof document.execCommand === 'function' && document.execCommand('copy')
document.body.removeChild(textArea)
if (selection) {
selection.removeAllRanges()
savedRanges.forEach(range => selection.addRange(range))
}
activeElement?.focus()
return copied
}
export async function copyTextToClipboard(text: string): Promise<void> {
let clipboardError: unknown
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return
} catch (error) {
clipboardError = error
}
}
if (fallbackCopyText(text)) {
return
}
throw clipboardError instanceof Error ? clipboardError : new Error('Failed to copy text to clipboard')
}
export async function readTextFromClipboard(): Promise<string> {
if (typeof navigator !== 'undefined' && navigator.clipboard?.readText) {
return navigator.clipboard.readText()
}
throw new Error('Clipboard read API is not available')
}
+19
View File
@@ -0,0 +1,19 @@
import { MS_PER_MINUTE, MS_PER_HOUR, MS_PER_DAY } from '../constants'
export function formatRelativeTime(timestamp: number): string {
if (!timestamp) return ''
const now = Date.now()
const diff = now - timestamp
const minutes = Math.floor(diff / MS_PER_MINUTE)
const hours = Math.floor(diff / MS_PER_HOUR)
const days = Math.floor(diff / MS_PER_DAY)
if (minutes < 1) return 'Just now'
if (minutes < 60) return `${minutes}m ago`
if (hours < 24) return `${hours}h ago`
if (days < 7) return `${days}d ago`
return new Date(timestamp).toLocaleDateString()
}
+28
View File
@@ -0,0 +1,28 @@
export function extractContentFromUnifiedDiff(diff: string): { before: string; after: string } {
let before = ''
let after = ''
for (const line of diff.split('\n')) {
if (
line.startsWith('---') ||
line.startsWith('+++') ||
line.startsWith('Index:') ||
line.startsWith('===') ||
line.startsWith('@@') ||
line.startsWith('\\ No newline')
) {
continue
}
if (line.startsWith('-')) {
before += line.slice(1) + '\n'
} else if (line.startsWith('+')) {
after += line.slice(1) + '\n'
} else if (line.startsWith(' ')) {
before += line.slice(1) + '\n'
after += line.slice(1) + '\n'
}
}
return { before: before.trimEnd(), after: after.trimEnd() }
}
+299
View File
@@ -0,0 +1,299 @@
// ============================================
// Directory Path Utilities
// 统一的目录路径处理工具
// ============================================
//
// 设计原则:
// 1. 前端内部存储:统一使用正斜杠 (URL友好,跨平台)
// 2. 后端 API 请求:根据 pathMode 决定用正斜杠还是反斜杠
// 3. 路径比较:统一规范化后比较(正斜杠 + 小写 + 无末尾斜杠)
//
import { serverStorage } from './perServerStorage'
// ============================================
// Path Mode Configuration
// ============================================
/**
* 路径模式
* - auto: 自动检测(根据后端响应判断)
* - unix: 强制使用正斜杠 /
* - windows: 强制使用反斜杠 \
*/
export type PathMode = 'auto' | 'unix' | 'windows'
/**
* 检测到的实际路径风格
*/
export type DetectedPathStyle = 'unix' | 'windows'
const STORAGE_KEY_PATH_MODE = 'opencode-path-mode'
const STORAGE_KEY_DETECTED_STYLE = 'opencode-detected-path-style'
let _pathMode: PathMode | null = null
let _detectedStyle: DetectedPathStyle | null = null
/**
* 重置路径模式缓存(服务器切换时调用)
* 下次 getPathMode / getDetectedPathStyle 会从 serverStorage 重新读取
*/
export function resetPathModeCache(): void {
_pathMode = null
_detectedStyle = null
}
// Path mode change listeners
type PathModeListener = (mode: PathMode, effectiveStyle: DetectedPathStyle) => void
const _listeners: Set<PathModeListener> = new Set()
/**
* 订阅路径模式变化
*/
export function subscribePathMode(listener: PathModeListener): () => void {
_listeners.add(listener)
return () => _listeners.delete(listener)
}
/**
* 通知所有监听器
*/
function notifyListeners(): void {
const mode = getPathMode()
const style = getEffectivePathStyle()
_listeners.forEach(listener => listener(mode, style))
}
/**
* 获取当前路径模式设置
*/
export function getPathMode(): PathMode {
if (_pathMode === null) {
try {
const saved = serverStorage.get(STORAGE_KEY_PATH_MODE)
if (saved === 'unix' || saved === 'windows') {
_pathMode = saved
} else {
_pathMode = 'auto' // 默认自动检测
}
} catch {
_pathMode = 'auto'
}
}
return _pathMode
}
/**
* 设置路径模式
*/
export function setPathMode(mode: PathMode): void {
_pathMode = mode
try {
serverStorage.set(STORAGE_KEY_PATH_MODE, mode)
} catch {
// ignore
}
notifyListeners()
}
/**
* 获取检测到的路径风格
*/
export function getDetectedPathStyle(): DetectedPathStyle {
if (_detectedStyle === null) {
try {
const saved = serverStorage.get(STORAGE_KEY_DETECTED_STYLE)
_detectedStyle = saved === 'windows' ? 'windows' : 'unix'
} catch {
_detectedStyle = 'unix'
}
}
return _detectedStyle
}
/**
* 设置检测到的路径风格(由自动检测逻辑调用)
*/
export function setDetectedPathStyle(style: DetectedPathStyle): void {
const previousStyle = _detectedStyle
_detectedStyle = style
try {
serverStorage.set(STORAGE_KEY_DETECTED_STYLE, style)
} catch {
// ignore
}
// 只在 auto 模式且风格变化时通知
if (getPathMode() === 'auto' && previousStyle !== style) {
notifyListeners()
}
}
/**
* 获取实际生效的路径风格
* - 如果是 auto 模式,返回检测到的风格
* - 否则返回用户设置的模式
*/
export function getEffectivePathStyle(): DetectedPathStyle {
const mode = getPathMode()
if (mode === 'auto') {
return getDetectedPathStyle()
}
return mode
}
/**
* 是否为 Windows 路径模式(实际生效的)
*/
export function isWindowsPathMode(): boolean {
return getEffectivePathStyle() === 'windows'
}
/**
* 根据当前模式格式化路径用于 API 请求
* - unix 模式: 转正斜杠
* - windows 模式: 转反斜杠
*/
export function formatPathForApi(dir: string | undefined | null): string | undefined {
if (!dir) return undefined
let trimmed = dir.replace(/[/\\]+$/, '') // 移除末尾斜杠
// 根路径保护:/ → "",C:/ → "C:",需要恢复斜杠
if (!trimmed) {
trimmed = '/'
} else if (/^[a-zA-Z]:$/.test(trimmed)) {
trimmed = trimmed + '/'
}
if (isWindowsPathMode()) {
return trimmed.replace(/\//g, '\\')
} else {
return trimmed.replace(/\\/g, '/')
}
}
/**
* 从后端响应中检测路径风格
* 分析路径字符串,判断后端使用的是哪种斜杠风格
*/
export function detectPathStyleFromResponse(path: string | undefined | null): DetectedPathStyle | null {
if (!path) return null
const backslashCount = (path.match(/\\/g) || []).length
const forwardSlashCount = (path.match(/\//g) || []).length
// 如果有反斜杠且多于正斜杠,判定为 Windows 风格
if (backslashCount > 0 && backslashCount >= forwardSlashCount) {
return 'windows'
}
// 如果有正斜杠,判定为 Unix 风格
if (forwardSlashCount > 0) {
return 'unix'
}
return null
}
/**
* 自动检测并更新路径风格
* 调用此函数时传入后端返回的路径,会自动更新检测结果
*/
export function autoDetectPathStyle(path: string | undefined | null): void {
const detected = detectPathStyleFromResponse(path)
if (detected) {
setDetectedPathStyle(detected)
}
}
// ============================================
// Path Normalization (Internal Use)
// ============================================
/**
* 规范化目录路径为正斜杠格式
* 用于前端内部存储和 URL
*
* @example
* normalizeToForwardSlash('E:\\dev\\project') // 'E:/dev/project'
* normalizeToForwardSlash('E:/dev/project/') // 'E:/dev/project'
*/
export function normalizeToForwardSlash(dir: string | undefined | null): string {
if (!dir) return ''
return dir
.replace(/\\/g, '/') // 反斜杠 → 正斜杠
.replace(/\/+$/, '') // 移除末尾斜杠
}
/**
* 规范化目录路径用于比较
* - 统一使用正斜杠
* - 移除末尾斜杠
* - 转小写(Windows 路径不区分大小写)
*
* @example
* normalizeForComparison('E:\\Dev\\Project') // 'e:/dev/project'
*/
export function normalizeForComparison(dir: string | undefined | null): string {
if (!dir) return ''
return dir
.replace(/\\/g, '/') // 反斜杠 → 正斜杠
.replace(/\/+$/, '') // 移除末尾斜杠
.toLowerCase() // Windows 路径不区分大小写
}
/**
* 比较两个目录路径是否相同
* 处理斜杠、大小写差异
*
* @example
* isSameDirectory('E:\\dev\\project', 'E:/dev/project') // true
* isSameDirectory('E:/Dev/Project', 'e:/dev/project') // true
*/
export function isSameDirectory(dir1: string | undefined | null, dir2: string | undefined | null): boolean {
return normalizeForComparison(dir1) === normalizeForComparison(dir2)
}
/**
* 从路径中提取目录名(最后一个部分)
*
* @example
* getDirectoryName('E:/dev/my-project') // 'my-project'
* getDirectoryName('E:\\dev\\my-project\\') // 'my-project'
*/
export function getDirectoryName(dir: string | undefined | null): string {
if (!dir) return ''
const normalized = normalizeToForwardSlash(dir)
const parts = normalized.split('/').filter(Boolean)
const name = parts[parts.length - 1] || normalized
// 解码 URL 编码的中文等字符
try {
return decodeURIComponent(name)
} catch {
return name
}
}
/**
* 检查路径是否是 Windows 绝对路径
*
* @example
* isWindowsAbsolutePath('E:/dev') // true
* isWindowsAbsolutePath('/home/user') // false
*/
export function isWindowsAbsolutePath(path: string): boolean {
return /^[a-zA-Z]:/.test(path)
}
/**
* 检查路径是否是绝对路径(Windows 或 Unix)
*/
export function isAbsolutePath(path: string): boolean {
return isWindowsAbsolutePath(path) || path.startsWith('/')
}
// ============================================
// Re-export from stringUtils for compatibility
// ============================================
// 保持向后兼容,逐步废弃 stringUtils 中的目录函数
export { normalizeForComparison as normalizeDirectoryPath }
+101
View File
@@ -0,0 +1,101 @@
// ============================================
// 文件下载工具函数
// 支持文本文件和二进制文件(base64)的下载
// 浏览器环境使用 <a download>,Tauri 环境使用原生保存对话框
// ============================================
import type { FileContent } from '../api/types'
import { isBinaryContent } from './mimeUtils'
import { isTauri } from './tauri'
/**
* 将 base64 字符串转为 Uint8Array
*/
function base64ToBytes(base64: string): Uint8Array {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
/**
* 触发浏览器下载(仅浏览器环境)
*/
function triggerBrowserDownload(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.style.display = 'none'
document.body.appendChild(a)
a.click()
// 延迟清理,确保下载已启动
setTimeout(() => {
URL.revokeObjectURL(url)
document.body.removeChild(a)
}, 100)
}
/**
* Tauri 原生保存文件
* 弹出系统保存对话框,用户选择路径后写入文件
*/
async function tauriSaveFile(data: Uint8Array, fileName: string): Promise<void> {
const [{ save }, { writeFile }] = await Promise.all([
import('@tauri-apps/plugin-dialog'),
import('@tauri-apps/plugin-fs'),
])
// 从文件名提取扩展名,用于对话框过滤
const ext = fileName.split('.').pop()?.toLowerCase()
const filters = ext ? [{ name: ext.toUpperCase(), extensions: [ext] }] : []
const filePath = await save({
defaultPath: fileName,
filters,
})
if (!filePath) return // 用户取消
await writeFile(filePath, data)
}
/**
* 从 FileContent 下载文件
* - 二进制文件:从 base64 解码后下载
* - 文本文件:直接以 UTF-8 编码下载
* - Tauri 环境:弹出原生保存对话框
* - 浏览器环境:使用 <a download> 触发下载
*/
export function downloadFileContent(content: FileContent, fileName: string): void {
const encoding = content.encoding || ''
const textContent = content.content || ''
// 统一转为 Uint8Array
const data = isBinaryContent(encoding)
? base64ToBytes(textContent)
: new TextEncoder().encode(textContent)
const mimeType = isBinaryContent(encoding)
? content.mimeType || 'application/octet-stream'
: `${content.mimeType || 'text/plain'};charset=utf-8`
saveData(data, fileName, mimeType)
}
/**
* 通用保存:接受原始数据 + 文件名 + MIME 类型
* - Tauri 环境:弹出原生保存对话框 + fs 写入
* - 浏览器环境:Blob + <a download>
*/
export function saveData(data: Uint8Array, fileName: string, mimeType = 'application/octet-stream'): void {
if (isTauri()) {
tauriSaveFile(data, fileName).catch(err => {
console.warn('[downloadUtils] Tauri save failed:', err)
})
} else {
const blob = new Blob([data.buffer as ArrayBuffer], { type: mimeType })
triggerBrowserDownload(blob, fileName)
}
}
+90
View File
@@ -0,0 +1,90 @@
// ============================================
// 统一错误处理工具
// ============================================
export type ErrorCategory =
| 'api' // API 调用错误
| 'session' // Session 相关错误
| 'permission' // 权限相关错误
| 'ui' // UI 交互错误
| 'parse' // 解析错误
| 'sse' // SSE 事件流错误
| 'file' // 文件操作错误
| 'syntax' // 语法高亮错误
| 'global' // 全局错误
| 'revert' // 撤销/重做错误
| 'clipboard' // 剪贴板错误
| 'unknown' // 未分类错误
export interface ErrorContext {
category: ErrorCategory
operation: string
silent?: boolean // 静默错误,不显示给用户
details?: unknown // 额外的错误详情
}
/**
* 统一的错误日志函数
*
* 未来可以扩展为:
* - 发送到错误监控服务
* - 显示 toast 通知
* - 根据错误类型做不同处理
*/
export function logError(error: unknown, context: ErrorContext): void {
const { category, operation, silent = false } = context
// 始终输出到控制台以便调试生产环境问题
console.error(`[${category}] ${operation}:`, error)
// 非静默错误,未来可以显示 toast
if (!silent) {
// TODO: 集成 toast 通知系统
// showToast({ type: 'error', message: `${operation} failed` })
}
}
/**
* 包装异步函数,自动处理错误
*/
export function withErrorHandling<T>(fn: () => Promise<T>, context: ErrorContext): Promise<T | undefined> {
return fn().catch(error => {
logError(error, context)
return undefined
})
}
/**
* 创建带上下文的错误处理器
* 用于同一模块内多次使用
*/
export function createErrorHandler(category: ErrorCategory) {
return (operation: string, error: unknown, silent = false, details?: unknown) => {
logError(error, { category, operation, silent, details })
}
}
// ============================================
// 预定义的错误处理器 - 按模块分类
// ============================================
export const apiErrorHandler = createErrorHandler('api')
export const sessionErrorHandler = createErrorHandler('session')
export const permissionErrorHandler = createErrorHandler('permission')
export const uiErrorHandler = createErrorHandler('ui')
export const parseErrorHandler = createErrorHandler('parse')
export const sseErrorHandler = createErrorHandler('sse')
export const fileErrorHandler = createErrorHandler('file')
export const syntaxErrorHandler = createErrorHandler('syntax')
export const globalErrorHandler = createErrorHandler('global')
export const revertErrorHandler = createErrorHandler('revert')
export const clipboardErrorHandler = createErrorHandler('clipboard')
/**
* 简单的 catch 处理器,用于 .catch(handleError('operation'))
*/
export function handleError(operation: string, category: ErrorCategory = 'unknown') {
return (error: unknown) => {
logError(error, { category, operation })
}
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { formatCompletedAt, formatDateTime, formatDetailedDateTime, formatDuration, formatTime } from './formatUtils'
describe('formatDuration', () => {
it('formats milliseconds and seconds', () => {
expect(formatDuration(999)).toBe('999ms')
expect(formatDuration(123.456)).toBe('123ms')
expect(formatDuration(1500)).toBe('1.5s')
})
it('formats minute and second durations', () => {
expect(formatDuration((3 * 60 + 12) * 1000)).toBe('3m 12s')
})
it('formats hour durations as hours, minutes, and seconds', () => {
expect(formatDuration((1 * 60 * 60 + 21 * 60 + 12) * 1000)).toBe('1h 21m 12s')
expect(formatDuration(60 * 60 * 1000)).toBe('1h')
})
it('formats day durations as days, hours, and minutes', () => {
expect(formatDuration(((24 + 21) * 60 + 10) * 60 * 1000)).toBe('1d 21h 10m')
expect(formatDuration(24 * 60 * 60 * 1000)).toBe('1d')
})
})
describe('completed time formatting', () => {
it('formats local time as HH:MM', () => {
const ms = new Date(2026, 3, 17, 10, 30, 45).getTime()
expect(formatTime(ms)).toBe('10:30')
})
it('formats local date and time as YYYY-MM-DD HH:MM', () => {
const ms = new Date(2026, 3, 17, 10, 30, 45).getTime()
expect(formatDateTime(ms)).toBe('2026-04-17 10:30')
})
it('formats detailed local date and time with seconds', () => {
const ms = new Date(2026, 3, 17, 10, 30, 45).getTime()
expect(formatDetailedDateTime(ms)).toBe('2026-04-17 10:30:45')
})
it('switches output based on completedAt format', () => {
const ms = new Date(2026, 3, 17, 10, 30, 45).getTime()
expect(formatCompletedAt(ms, 'time')).toBe('10:30')
expect(formatCompletedAt(ms, 'dateTime')).toBe('2026-04-17 10:30')
})
})
+79
View File
@@ -0,0 +1,79 @@
/**
* Shared formatting utilities.
* Consolidated from duplicated functions across message parts, hooks, and renderers.
*/
import type { CompletedAtFormat } from '../store/themeStore'
/** Format a tool name for display: "my-tool_name" → "My Tool Name" */
export function formatToolName(name: string): string {
return name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
/** Format a duration in ms to human-readable string */
export function formatDuration(ms: number): string {
const normalizedMs = Math.max(0, Math.round(ms))
if (normalizedMs < 1000) return `${normalizedMs}ms`
const s = normalizedMs / 1000
if (s < 60) return `${s.toFixed(1)}s`
const totalSeconds = Math.round(s)
const d = Math.floor(totalSeconds / (60 * 60 * 24))
const h = Math.floor((totalSeconds % (60 * 60 * 24)) / (60 * 60))
const m = Math.floor((totalSeconds % (60 * 60)) / 60)
const remS = totalSeconds % 60
if (d > 0) return [`${d}d`, h > 0 ? `${h}h` : '', m > 0 ? `${m}m` : ''].filter(Boolean).join(' ')
if (h > 0) return [`${h}h`, m > 0 ? `${m}m` : '', remS > 0 ? `${remS}s` : ''].filter(Boolean).join(' ')
return [`${m}m`, remS > 0 ? `${remS}s` : ''].filter(Boolean).join(' ')
}
/** Format a cost in dollars */
export function formatCost(cost: number): string {
if (cost === 0) return '$0'
if (cost < 0.001) return '<$0.001'
if (cost < 0.01) return '$' + cost.toFixed(3)
if (cost < 1) return '$' + cost.toFixed(2)
return '$' + cost.toFixed(2)
}
/** Format a timestamp (ms) to local HH:MM time string */
export function formatTime(ms: number): string {
const date = new Date(ms)
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
return `${h}:${m}`
}
/** Format a timestamp (ms) to local YYYY-MM-DD HH:MM string */
export function formatDateTime(ms: number): string {
const date = new Date(ms)
const y = date.getFullYear().toString().padStart(4, '0')
const mon = (date.getMonth() + 1).toString().padStart(2, '0')
const d = date.getDate().toString().padStart(2, '0')
return `${y}-${mon}-${d} ${formatTime(ms)}`
}
/** Format a timestamp (ms) to local YYYY-MM-DD HH:MM:SS string for detail tooltips */
export function formatDetailedDateTime(ms: number): string {
const date = new Date(ms)
const y = date.getFullYear().toString().padStart(4, '0')
const mon = (date.getMonth() + 1).toString().padStart(2, '0')
const d = date.getDate().toString().padStart(2, '0')
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
const s = date.getSeconds().toString().padStart(2, '0')
return `${y}-${mon}-${d} ${h}:${m}:${s}`
}
/** Format completed time according to the selected display mode */
export function formatCompletedAt(ms: number, format: CompletedAtFormat): string {
return format === 'dateTime' ? formatDateTime(ms) : formatTime(ms)
}
/** Format a large number with k/M suffix */
export function formatNumber(num: number): string {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
if (num >= 1000) return (num / 1000).toFixed(1) + 'k'
return num.toString()
}
+9
View File
@@ -0,0 +1,9 @@
export * from './sessionHelpers'
export * from './activeScope'
export * from './errorHandling'
export * from './clipboard'
export { getInitials } from './stringUtils'
export * from './directoryUtils'
export { serverStorage } from './perServerStorage'
export * from './mimeUtils'
export { logger } from './logger'
+131
View File
@@ -0,0 +1,131 @@
// Language normalization helpers
import { isSupportedLanguage } from '../lib/shiki'
export { isSupportedLanguage }
export function normalizeLanguage(lang: string): string {
if (!lang) return 'text'
// shiki 自身已支持绝大部分别名(js, ts, py, sh, yml, md, cs, c++, c# 等)
// 只映射 shiki 确实不认识的
const aliases: Record<string, string> = {
golang: 'go',
}
return aliases[lang.toLowerCase()] || lang.toLowerCase()
}
export function detectLanguage(filePath?: string): string {
if (!filePath) return 'text'
// 先检查完整文件名
const fileName = filePath.split(/[/\\]/).pop()?.toLowerCase() || ''
const fileNameMap: Record<string, string> = {
'.gitignore': 'gitignore',
'.dockerignore': 'gitignore',
'.npmignore': 'gitignore',
'.eslintignore': 'gitignore',
'.prettierignore': 'gitignore',
dockerfile: 'dockerfile',
makefile: 'makefile',
'cmakelists.txt': 'cmake',
'.env': 'dotenv',
'.env.local': 'dotenv',
'.env.development': 'dotenv',
'.env.production': 'dotenv',
'.editorconfig': 'ini',
'.npmrc': 'ini',
'.yarnrc': 'yaml',
'tsconfig.json': 'jsonc',
'jsconfig.json': 'jsonc',
'.prettierrc': 'json',
'.eslintrc': 'json',
'package.json': 'json',
'composer.json': 'json',
'cargo.toml': 'toml',
'pyproject.toml': 'toml',
}
if (fileNameMap[fileName]) {
return fileNameMap[fileName]
}
// 然后检查扩展名
const ext = filePath.split('.').pop()?.toLowerCase() || ''
const extMap: Record<string, string> = {
js: 'javascript',
jsx: 'jsx',
ts: 'typescript',
tsx: 'tsx',
py: 'python',
rb: 'ruby',
go: 'go',
rs: 'rust',
java: 'java',
c: 'c',
cpp: 'cpp',
h: 'c',
hpp: 'cpp',
cs: 'csharp',
php: 'php',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
json: 'json',
yaml: 'yaml',
yml: 'yaml',
md: 'markdown',
html: 'html',
css: 'css',
scss: 'scss',
less: 'less',
sql: 'sql',
xml: 'xml',
toml: 'toml',
vue: 'vue',
svelte: 'svelte',
ini: 'ini',
conf: 'ini',
cfg: 'ini',
dockerfile: 'dockerfile',
makefile: 'makefile',
gitignore: 'gitignore',
env: 'dotenv',
kt: 'kotlin',
kts: 'kotlin',
swift: 'swift',
m: 'objective-c',
mm: 'objective-cpp',
r: 'r',
R: 'r',
lua: 'lua',
pl: 'perl',
pm: 'perl',
ex: 'elixir',
exs: 'elixir',
erl: 'erlang',
hrl: 'erlang',
hs: 'haskell',
lhs: 'haskell',
clj: 'clojure',
cljs: 'clojure',
cljc: 'clojure',
scala: 'scala',
sc: 'scala',
groovy: 'groovy',
gradle: 'groovy',
ps1: 'powershell',
psm1: 'powershell',
fish: 'fish',
vim: 'viml',
vimrc: 'viml',
tf: 'terraform',
tfvars: 'terraform',
proto: 'protobuf',
graphql: 'graphql',
gql: 'graphql',
prisma: 'prisma',
}
return extMap[ext] || 'text'
}
+14
View File
@@ -0,0 +1,14 @@
export function getLineCount(text: string): number {
if (text.length === 0) return 1
let count = 1
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) count++
}
return count
}
export function getLineNumberColumnWidth(maxLineNo: number): number {
const digits = String(Math.max(1, maxLineNo)).length
return Math.max(44, digits * 8 + 28)
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Dev-only logger utility.
* - `logger.log` / `logger.debug` are silenced in production builds.
* - `logger.warn` / `logger.error` always output (they indicate real issues).
*
* Vite will tree-shake the dev-only branches in production.
*/
const isDev = import.meta.env.DEV
const noop = (..._args: unknown[]) => {}
export const logger = {
/** Dev-only informational log */
log: isDev ? console.log.bind(console) : noop,
/** Dev-only debug log */
debug: isDev ? console.debug.bind(console) : noop,
/** Always outputs — indicates a potential problem */
warn: console.warn.bind(console),
/** Always outputs — indicates a real error */
error: console.error.bind(console),
}
+461
View File
@@ -0,0 +1,461 @@
// ============================================
// Material Icon Theme - Lean Icon Resolver
// Hand-written mapping for ~80 common file types + folders
// SVGs served from public/material-icons/ (copied at postinstall)
// ============================================
/** icon SVG filename (without .svg) */
type Icon = string
// ---- file name → icon (exact match, lowercase) ----
const FILE_NAMES: Record<string, Icon> = {
// Package managers & Node
'package.json': 'nodejs',
'package-lock.json': 'nodejs',
'yarn.lock': 'yarn',
'pnpm-lock.yaml': 'pnpm',
'bun.lock': 'bun',
'bun.lockb': 'bun',
'bunfig.toml': 'bun',
'.nvmrc': 'nodejs',
'.node-version': 'nodejs',
// Docker
dockerfile: 'docker',
'docker-compose.yml': 'docker',
'docker-compose.yaml': 'docker',
'.dockerignore': 'docker',
// Git
'.gitignore': 'git',
'.gitattributes': 'git',
'.gitmodules': 'git',
// TS / JS config
'tsconfig.json': 'tsconfig',
'jsconfig.json': 'jsconfig',
// Linters & formatters
'.eslintrc': 'eslint',
'.eslintrc.js': 'eslint',
'.eslintrc.json': 'eslint',
'.eslintrc.cjs': 'eslint',
'eslint.config.js': 'eslint',
'eslint.config.ts': 'eslint',
'eslint.config.mjs': 'eslint',
'.prettierrc': 'prettier',
'.prettierrc.js': 'prettier',
'.prettierrc.json': 'prettier',
'.prettierrc.yml': 'prettier',
// Build tools
'vite.config.js': 'vite',
'vite.config.ts': 'vite',
'vite.config.mts': 'vite',
'webpack.config.js': 'webpack',
'webpack.config.ts': 'webpack',
'rollup.config.js': 'rollup',
'rollup.config.ts': 'rollup',
'turbo.json': 'turborepo',
'gulpfile.js': 'gulp',
makefile: 'makefile',
cmake: 'cmake',
'cmakelists.txt': 'cmake',
// Framework config
'tailwind.config.js': 'tailwindcss',
'tailwind.config.ts': 'tailwindcss',
'next.config.js': 'next',
'next.config.mjs': 'next',
'next.config.ts': 'next',
'nuxt.config.js': 'nuxt',
'nuxt.config.ts': 'nuxt',
'svelte.config.js': 'svelte',
'astro.config.mjs': 'astro-config',
'astro.config.js': 'astro-config',
'astro.config.ts': 'astro-config',
'angular.json': 'angular',
'vue.config.js': 'vue-config',
// Test
'jest.config.js': 'jest',
'jest.config.ts': 'jest',
'vitest.config.js': 'vitest',
'vitest.config.ts': 'vitest',
// Language-specific
'cargo.toml': 'rust',
'cargo.lock': 'rust',
'go.mod': 'go-mod',
'go.sum': 'go-mod',
'requirements.txt': 'python',
'pyproject.toml': 'python',
pipfile: 'python',
gemfile: 'ruby',
rakefile: 'ruby',
'composer.json': 'php',
'build.gradle': 'gradle',
'pom.xml': 'maven',
'deno.json': 'deno',
'deno.jsonc': 'deno',
// Cloud / deploy
'vercel.json': 'vercel',
'netlify.toml': 'netlify',
'firebase.json': 'firebase',
// Env
'.env': 'settings',
'.env.local': 'settings',
'.env.development': 'settings',
'.env.production': 'settings',
'.env.example': 'settings',
'.editorconfig': 'settings',
// Docs
'readme.md': 'readme',
'changelog.md': 'changelog',
license: 'license',
'license.md': 'license',
// Misc
'.babelrc': 'babel',
'babel.config.js': 'babel',
'nx.json': 'json',
'.storybook': 'storybook',
}
// ---- file extension → icon (longest compound match first) ----
// Order matters: compound extensions like "spec.ts" should come before "ts"
const FILE_EXTENSIONS: [ext: string, icon: Icon][] = [
// Compound extensions (tested first)
['spec.ts', 'typescript'],
['test.ts', 'typescript'],
['spec.tsx', 'react_ts'],
['test.tsx', 'react_ts'],
['spec.js', 'javascript'],
['test.js', 'javascript'],
['spec.jsx', 'react'],
['test.jsx', 'react'],
['d.ts', 'typescript-def'],
['js.map', 'javascript-map'],
['css.map', 'css-map'],
// TypeScript / JavaScript
['ts', 'typescript'],
['tsx', 'react_ts'],
['js', 'javascript'],
['jsx', 'react'],
['mjs', 'javascript'],
['cjs', 'javascript'],
['mts', 'typescript'],
['cts', 'typescript'],
// Web
['html', 'html'],
['htm', 'html'],
['css', 'css'],
['scss', 'sass'],
['sass', 'sass'],
['less', 'less'],
// Data
['json', 'json'],
['jsonc', 'json'],
['json5', 'json'],
['xml', 'xml'],
['yml', 'yaml'],
['yaml', 'yaml'],
['toml', 'toml'],
['csv', 'json'],
// Docs
['md', 'markdown'],
['mdx', 'mdx'],
// Languages
['py', 'python'],
['pyw', 'python'],
['rs', 'rust'],
['go', 'go'],
['java', 'java'],
['kt', 'kotlin'],
['kts', 'kotlin'],
['scala', 'scala'],
['php', 'php'],
['rb', 'ruby'],
['cs', 'csharp'],
['fs', 'fsharp'],
['cpp', 'cpp'],
['cc', 'cpp'],
['cxx', 'cpp'],
['c', 'c'],
['h', 'c'],
['hpp', 'cpp'],
['swift', 'swift'],
['dart', 'dart'],
['lua', 'lua'],
['pl', 'perl'],
['r', 'r'],
['jl', 'julia'],
['hs', 'haskell'],
['elm', 'settings'],
['ml', 'ocaml'],
['clj', 'clojure'],
['cljs', 'clojure'],
['ex', 'elixir'],
['exs', 'elixir'],
['erl', 'erlang'],
['nim', 'nim'],
['zig', 'zig'],
['asm', 'assembly'],
['s', 'assembly'],
// Shell
['sh', 'console'],
['bash', 'console'],
['zsh', 'console'],
['fish', 'console'],
['ps1', 'powershell'],
// Framework
['vue', 'vue'],
['svelte', 'svelte'],
['astro', 'astro'],
// Config
['cfg', 'settings'],
['ini', 'settings'],
['conf', 'settings'],
['properties', 'settings'],
['env', 'settings'],
['lock', 'lock'],
// Media
['svg', 'svg'],
['png', 'image'],
['jpg', 'image'],
['jpeg', 'image'],
['gif', 'image'],
['webp', 'image'],
['ico', 'image'],
['bmp', 'image'],
['mp4', 'video'],
['mov', 'video'],
['avi', 'video'],
['webm', 'video'],
['mp3', 'audio'],
['wav', 'audio'],
['flac', 'audio'],
// Archive
['zip', 'zip'],
['tar', 'zip'],
['gz', 'zip'],
['rar', 'zip'],
['7z', 'zip'],
// Documents
['pdf', 'pdf'],
// Database
['sql', 'database'],
['db', 'database'],
['sqlite', 'database'],
// Other
['log', 'settings'],
['key', 'key'],
['pem', 'certificate'],
['crt', 'certificate'],
['proto', 'proto'],
['graphql', 'graphql'],
['gql', 'graphql'],
['wasm', 'document'],
['prisma', 'prisma'],
['ttf', 'font'],
['otf', 'font'],
['woff', 'font'],
['woff2', 'font'],
]
// ---- folder name → icon SVG base name (without -open suffix) ----
// We'll append "-open" for expanded state automatically
const FOLDER_NAMES: Record<string, Icon> = {
src: 'folder-src',
source: 'folder-src',
lib: 'folder-lib',
libs: 'folder-lib',
// Test
test: 'folder-test',
tests: 'folder-test',
__tests__: 'folder-test',
spec: 'folder-test',
specs: 'folder-test',
e2e: 'folder-test',
cypress: 'folder-cypress',
// Dependencies
node_modules: 'folder-node',
vendor: 'folder-packages',
packages: 'folder-packages',
// Build
build: 'folder-buildkite',
dist: 'folder-dist',
out: 'folder-dist',
output: 'folder-dist',
target: 'folder-target',
// Config
config: 'folder-config',
configs: 'folder-config',
settings: 'folder-config',
env: 'folder-environment',
// Docker
docker: 'folder-docker',
// Docs
docs: 'folder-docs',
doc: 'folder-docs',
documentation: 'folder-docs',
// Assets
public: 'folder-public',
static: 'folder-public',
assets: 'folder-images',
images: 'folder-images',
img: 'folder-images',
icons: 'folder-images',
media: 'folder-images',
fonts: 'folder-font',
// Styles
styles: 'folder-css',
css: 'folder-css',
scss: 'folder-sass',
sass: 'folder-sass',
less: 'folder-less',
// Code structure
components: 'folder-components',
component: 'folder-components',
views: 'folder-views',
view: 'folder-views',
pages: 'folder-views',
layouts: 'folder-layout',
layout: 'folder-layout',
templates: 'folder-template',
template: 'folder-template',
hooks: 'folder-hook',
hook: 'folder-hook',
store: 'folder-store',
stores: 'folder-store',
state: 'folder-ngrx-store',
services: 'folder-api',
service: 'folder-api',
api: 'folder-api',
apis: 'folder-api',
routes: 'folder-routes',
route: 'folder-routes',
routing: 'folder-routes',
middleware: 'folder-middleware',
middlewares: 'folder-middleware',
controllers: 'folder-controller',
controller: 'folder-controller',
models: 'folder-database',
model: 'folder-database',
schemas: 'folder-database',
schema: 'folder-database',
migrations: 'folder-database',
database: 'folder-database',
db: 'folder-database',
prisma: 'folder-prisma',
drizzle: 'folder-drizzle',
// Scripts & utils
scripts: 'folder-scripts',
script: 'folder-scripts',
tools: 'folder-tools',
utils: 'folder-utils',
utilities: 'folder-utils',
helpers: 'folder-helper',
// Types
types: 'folder-typescript',
typings: 'folder-typescript',
'@types': 'folder-typescript',
interfaces: 'folder-interface',
// i18n
i18n: 'folder-i18n',
locales: 'folder-i18n',
locale: 'folder-i18n',
lang: 'folder-i18n',
// Infra
kubernetes: 'folder-kubernetes',
k8s: 'folder-kubernetes',
terraform: 'folder-terraform',
firebase: 'folder-firebase',
supabase: 'folder-supabase',
vercel: 'folder-vercel',
netlify: 'folder-netlify',
// CI/CD
'.github': 'folder-github',
'.gitlab': 'folder-gitlab',
'.circleci': 'folder-circleci',
ci: 'folder-ci',
workflows: 'folder-gh-workflows',
// Dev tools
'.git': 'folder-git',
'.vscode': 'folder-vscode',
'.idea': 'folder-intellij',
'.cursor': 'folder-cursor',
'.devcontainer': 'folder-container',
'.storybook': 'folder-storybook',
// Platform
android: 'folder-android',
ios: 'folder-ios',
mobile: 'folder-mobile',
desktop: 'folder-desktop',
windows: 'folder-windows',
linux: 'folder-linux',
macos: 'folder-macos',
// Other
temp: 'folder-temp',
tmp: 'folder-temp',
logs: 'folder-log',
log: 'folder-log',
examples: 'folder-examples',
example: 'folder-examples',
demo: 'folder-examples',
samples: 'folder-examples',
mocks: 'folder-mock',
mock: 'folder-mock',
fixtures: 'folder-test',
data: 'folder-database',
content: 'folder-content',
functions: 'folder-functions',
serverless: 'folder-serverless',
server: 'folder-server',
auth: 'folder-secure',
security: 'folder-secure',
keys: 'folder-keys',
certs: 'folder-keys',
queue: 'folder-queue',
jobs: 'folder-job',
tasks: 'folder-tasks',
// Framework
'src-tauri': 'folder-src-tauri',
core: 'folder-core',
shared: 'folder-shared',
context: 'folder-context',
}
// ---- defaults ----
const DEFAULT_FILE: Icon = 'file'
const DEFAULT_FOLDER: Icon = 'folder'
const DEFAULT_FOLDER_OPEN: Icon = 'folder-open'
// ---- helpers ----
const _base = import.meta.env.BASE_URL
function basename(path: string): string {
return path.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? ''
}
function resolveFileIcon(path: string): Icon {
const name = basename(path).toLowerCase()
// 1. exact filename match
const byName = FILE_NAMES[name]
if (byName) return byName
// 2. extension match - try compound extensions first (longest match)
for (const [ext, icon] of FILE_EXTENSIONS) {
if (name.endsWith('.' + ext)) return icon
}
return DEFAULT_FILE
}
function resolveFolderIcon(path: string, expanded: boolean): Icon {
const name = basename(path).toLowerCase()
const base = FOLDER_NAMES[name]
if (base) return expanded ? base + '-open' : base
return expanded ? DEFAULT_FOLDER_OPEN : DEFAULT_FOLDER
}
/**
* Get the material icon SVG URL for a file or folder.
*/
export function getMaterialIconUrl(path: string, type: 'file' | 'directory', expanded = false): string {
const icon = type === 'directory' ? resolveFolderIcon(path, expanded) : resolveFileIcon(path)
return `${_base}material-icons/${icon}.svg`
}
+36
View File
@@ -0,0 +1,36 @@
import type { ApiMessage, ApiMessageWithParts, ApiPart } from '../api/types'
import type { Message, MessageInfo, Part, UserMessageInfo } from '../types/message'
import { isUserMessage } from '../types/message'
type ApiMessageEnvelope = ApiMessageWithParts | { message: ApiMessageWithParts['info']; parts: ApiMessageWithParts['parts'] }
function getEnvelopeInfo(apiMessage: ApiMessageEnvelope): ApiMessageWithParts['info'] {
return 'info' in apiMessage ? apiMessage.info : apiMessage.message
}
export function toUIMessage(apiMessage: ApiMessageEnvelope): Message {
return {
info: getEnvelopeInfo(apiMessage) as MessageInfo,
parts: apiMessage.parts as Part[],
isStreaming: false,
}
}
export function toUIMessageInfo(apiMessage: ApiMessage): MessageInfo {
return apiMessage as MessageInfo
}
export function toUIPart(apiPart: ApiPart): Part {
return apiPart as Part
}
export function toApiMessageWithParts(message: Pick<Message, 'info' | 'parts'>): ApiMessageWithParts {
return {
info: message.info as ApiMessageWithParts['info'],
parts: message.parts as ApiMessageWithParts['parts'],
}
}
export function isUserUIMessage(message: Message): message is Message & { info: UserMessageInfo } {
return isUserMessage(message.info)
}
+109
View File
@@ -0,0 +1,109 @@
// ============================================
// MIME 类型工具 - 统一处理文件类型分类与预览能力判断
// ============================================
/**
* 可直接作为预览渲染的媒体主类型(mimeType 的 "/" 前部分)
* image/* / audio/* / video/* 天然覆盖所有子类型
*/
const PREVIEWABLE_MEDIA = new Set(['image', 'audio', 'video'])
/**
* application/* 中可预览的子类型白名单
* 需要单独处理,因为 application 大类本身不可预览
*/
const PREVIEWABLE_APP_SUBTYPES = new Set(['pdf'])
/** 预览分类 */
export type PreviewCategory = 'image' | 'audio' | 'video' | 'pdf'
/**
* 从 mimeType 提取预览分类
* 利用 MIME 本身的 major/minor 结构做映射,无需逐个硬编码
*
* @returns 预览分类,null 表示不支持预览
*/
export function getPreviewCategory(mimeType?: string): PreviewCategory | null {
if (!mimeType) return null
const slash = mimeType.indexOf('/')
if (slash === -1) return null
const major = mimeType.slice(0, slash)
const minor = mimeType.slice(slash + 1)
if (PREVIEWABLE_MEDIA.has(major)) return major as PreviewCategory
if (major === 'application' && PREVIEWABLE_APP_SUBTYPES.has(minor)) return minor as PreviewCategory
return null
}
/**
* 判断一个 FileContent 是否为二进制内容
*/
export function isBinaryContent(encoding?: string): boolean {
return encoding === 'base64'
}
/**
* 文本编码但可渲染预览的 MIME 类型
* 这类文件同时支持「渲染预览」和「源码查看」两种模式
*/
const TEXTUAL_MEDIA_TYPES = new Set(['image/svg+xml'])
/**
* 判断是否为文本型可渲染媒体
* 区别于二进制媒体:这类文件以文本形式存储,但可以渲染出可视化效果
*/
export function isTextualMedia(mimeType?: string): boolean {
return !!mimeType && TEXTUAL_MEDIA_TYPES.has(mimeType)
}
/**
* 构建 base64 data URL
*/
export function buildDataUrl(mimeType: string, base64Content: string): string {
return `data:${mimeType};base64,${base64Content}`
}
/**
* 从文本内容构建 data URL(用于 SVG 等文本型媒体)
*/
export function buildTextDataUrl(mimeType: string, text: string): string {
return `data:${mimeType};charset=utf-8,${encodeURIComponent(text)}`
}
/**
* 将 base64 编码的内容解码为 UTF-8 文本
* 用于 SVG 等虽以 base64 传输但本质是文本的文件
*/
export function decodeBase64Text(base64: string): string {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return new TextDecoder('utf-8').decode(bytes)
}
/**
* 格式化 mimeType 用于显示
* e.g. "image/png" -> "PNG Image"
*/
export function formatMimeType(mimeType: string): string {
const slash = mimeType.indexOf('/')
if (slash === -1) return mimeType.toUpperCase()
const major = mimeType.slice(0, slash)
const minor = mimeType.slice(slash + 1).replace(/[+;].*$/, '') // 去掉参数如 svg+xml
const majorLabels: Record<string, string> = {
image: 'Image',
audio: 'Audio',
video: 'Video',
application: 'File',
text: 'Text',
font: 'Font',
}
const label = majorLabels[major] || 'File'
return `${minor.toUpperCase()} ${label}`
}
+401
View File
@@ -0,0 +1,401 @@
/**
* 模型工具函数
* - 唯一标识管理
* - 使用频率记录和排序
*/
import type { ModelInfo } from '../api'
import { serverStorage } from './perServerStorage'
// ============================================
// 模型唯一标识
// ============================================
/**
* 生成模型的唯一标识符
* 格式: providerId:modelId
*/
export function getModelKey(model: ModelInfo): string {
return `${model.providerId}:${model.id}`
}
/**
* 从唯一标识符解析 providerId 和 modelId
*/
export function parseModelKey(key: string): { providerId: string; modelId: string } | null {
const idx = key.indexOf(':')
if (idx === -1) return null
return {
providerId: key.slice(0, idx),
modelId: key.slice(idx + 1),
}
}
/**
* 根据 key 找到对应的模型
*/
export function findModelByKey(models: ModelInfo[], key: string): ModelInfo | undefined {
const parsed = parseModelKey(key)
if (!parsed) return undefined
return models.find(m => m.providerId === parsed.providerId && m.id === parsed.modelId)
}
// ============================================
// 使用频率存储
// ============================================
const STORAGE_KEY = 'model-usage-stats'
const VARIANT_STORAGE_KEY = 'model-variant-prefs'
const PINNED_STORAGE_KEY = 'model-pinned'
const SESSION_SELECTION_STORAGE_KEY = 'session-model-selection'
interface ModelUsageStats {
[modelKey: string]: {
count: number
lastUsed: number
}
}
interface ModelVariantPrefs {
[modelKey: string]: string // modelKey -> variant
}
interface SessionModelSelections {
[sessionId: string]: {
modelKey: string
variant?: string
}
}
/**
* 获取所有模型的使用统计
*/
export function getModelUsageStats(): ModelUsageStats {
try {
const stored = serverStorage.get(STORAGE_KEY)
return stored ? JSON.parse(stored) : {}
} catch {
return {}
}
}
/**
* 记录模型使用
*/
export function recordModelUsage(model: ModelInfo): void {
const key = getModelKey(model)
const stats = getModelUsageStats()
if (!stats[key]) {
stats[key] = { count: 0, lastUsed: 0 }
}
stats[key].count += 1
stats[key].lastUsed = Date.now()
try {
serverStorage.set(STORAGE_KEY, JSON.stringify(stats))
} catch (e) {
console.warn('Failed to save model usage stats:', e)
}
}
/**
* 获取模型的使用次数
*/
export function getModelUsageCount(model: ModelInfo): number {
const key = getModelKey(model)
const stats = getModelUsageStats()
return stats[key]?.count ?? 0
}
// ============================================
// Variant 偏好存储
// ============================================
/**
* 获取所有模型的 variant 偏好
*/
export function getModelVariantPrefs(): ModelVariantPrefs {
try {
const stored = serverStorage.get(VARIANT_STORAGE_KEY)
return stored ? JSON.parse(stored) : {}
} catch {
return {}
}
}
/**
* 保存模型的 variant 偏好
*/
export function saveModelVariantPref(modelKey: string, variant: string | undefined): void {
const prefs = getModelVariantPrefs()
if (variant) {
prefs[modelKey] = variant
} else {
delete prefs[modelKey]
}
try {
serverStorage.set(VARIANT_STORAGE_KEY, JSON.stringify(prefs))
} catch (e) {
console.warn('Failed to save model variant pref:', e)
}
}
/**
* 获取模型的 variant 偏好
*/
export function getModelVariantPref(modelKey: string): string | undefined {
const prefs = getModelVariantPrefs()
return prefs[modelKey]
}
// ============================================
// Session 模型选择存储
// ============================================
export function getSessionModelSelections(): SessionModelSelections {
try {
const stored = serverStorage.get(SESSION_SELECTION_STORAGE_KEY)
return stored ? JSON.parse(stored) : {}
} catch {
return {}
}
}
export function getSessionModelSelection(sessionId: string): { modelKey: string; variant?: string } | undefined {
const selections = getSessionModelSelections()
return selections[sessionId]
}
export function saveSessionModelSelection(sessionId: string, modelKey: string, variant: string | undefined): void {
const selections = getSessionModelSelections()
selections[sessionId] = variant ? { modelKey, variant } : { modelKey }
try {
serverStorage.set(SESSION_SELECTION_STORAGE_KEY, JSON.stringify(selections))
} catch (e) {
console.warn('Failed to save session model selection:', e)
}
}
// ============================================
// 模型排序
// ============================================
export type ModelSortMode = 'frequency' | 'alphabetical' | 'provider'
/**
* 根据使用频率排序模型
* - 最近使用的优先
* - 使用次数多的优先
* - 未使用过的按原始顺序
*/
export function sortModelsByFrequency(models: ModelInfo[]): ModelInfo[] {
const stats = getModelUsageStats()
return [...models].sort((a, b) => {
const keyA = getModelKey(a)
const keyB = getModelKey(b)
const statsA = stats[keyA]
const statsB = stats[keyB]
// 都没使用过,保持原始顺序
if (!statsA && !statsB) return 0
// 只有一个使用过,使用过的排前面
if (!statsA) return 1
if (!statsB) return -1
// 都使用过,按使用次数排序,次数相同按最近使用时间
if (statsA.count !== statsB.count) {
return statsB.count - statsA.count
}
return statsB.lastUsed - statsA.lastUsed
})
}
/**
* 按字母顺序排序模型
*/
export function sortModelsAlphabetically(models: ModelInfo[]): ModelInfo[] {
return [...models].sort((a, b) => a.name.localeCompare(b.name))
}
/**
* 按 provider 分组(返回分组后的扁平数组,常用的 provider 在前)
*/
export function sortModelsByProvider(models: ModelInfo[]): ModelInfo[] {
const stats = getModelUsageStats()
// 计算每个 provider 的总使用次数
const providerUsage: Record<string, number> = {}
for (const model of models) {
const key = getModelKey(model)
const count = stats[key]?.count ?? 0
providerUsage[model.providerId] = (providerUsage[model.providerId] ?? 0) + count
}
// 按 provider 使用频率分组
const groups = models.reduce(
(acc, model) => {
if (!acc[model.providerId]) {
acc[model.providerId] = []
}
acc[model.providerId].push(model)
return acc
},
{} as Record<string, ModelInfo[]>,
)
// 按 provider 使用频率排序
const sortedProviders = Object.keys(groups).sort((a, b) => {
return (providerUsage[b] ?? 0) - (providerUsage[a] ?? 0)
})
// 扁平化,每个 provider 内部按使用频率排序
return sortedProviders.flatMap(providerId => sortModelsByFrequency(groups[providerId]))
}
// ============================================
// 模型分组(用于 UI 展示)
// ============================================
export interface ModelGroup {
providerId: string
providerName: string
models: ModelInfo[]
}
/**
* 将模型按 provider 分组,常用的 provider 在前
*/
export function groupModelsByProvider(models: ModelInfo[]): ModelGroup[] {
const stats = getModelUsageStats()
// 按 providerName 分组 (合并同名 Provider)
const groupMap = models.reduce(
(acc, model) => {
const key = model.providerName // 使用 Name 作为分组键
if (!acc[key]) {
acc[key] = {
providerId: model.providerId, // 这里的 ID 仅作参考,可能不唯一
providerName: model.providerName,
models: [],
}
}
acc[key].models.push(model)
return acc
},
{} as Record<string, ModelGroup>,
)
// 按 provider 使用频率排序 (累加该 Name 下所有模型的使用次数)
const groups = Object.values(groupMap).sort((a, b) => {
// 计算 Group A 的总权重
const weightA = a.models.reduce((sum, m) => sum + (stats[getModelKey(m)]?.count ?? 0), 0)
// 计算 Group B 的总权重
const weightB = b.models.reduce((sum, m) => sum + (stats[getModelKey(m)]?.count ?? 0), 0)
return weightB - weightA
})
// 每个 provider 内部按使用频率排序
for (const group of groups) {
group.models = sortModelsByFrequency(group.models)
}
return groups
}
/**
* 获取最近使用的模型列表(用于快速访问)
*/
export function getRecentModels(models: ModelInfo[], limit = 5): ModelInfo[] {
const stats = getModelUsageStats()
// 过滤出使用过的模型并按最近使用时间排序
// 只要有记录且 count > 0 就视为使用过
const usedModels = models
.filter(m => {
const s = stats[getModelKey(m)]
return s && (s.count > 0 || s.lastUsed > 0)
})
.sort((a, b) => {
const statsA = stats[getModelKey(a)]
const statsB = stats[getModelKey(b)]
// 优先按时间倒序
return (statsB?.lastUsed ?? 0) - (statsA?.lastUsed ?? 0)
})
return usedModels.slice(0, limit)
}
// ============================================
// 模型置顶
// ============================================
/**
* 获取所有置顶的模型 key 列表(有序)
*/
export function getPinnedModelKeys(): string[] {
try {
const stored = serverStorage.get(PINNED_STORAGE_KEY)
return stored ? JSON.parse(stored) : []
} catch {
return []
}
}
/**
* 判断模型是否已置顶
*/
export function isModelPinned(model: ModelInfo): boolean {
const key = getModelKey(model)
return getPinnedModelKeys().includes(key)
}
/**
* 切换模型置顶状态
*/
export function toggleModelPin(model: ModelInfo): boolean {
const key = getModelKey(model)
const pinned = getPinnedModelKeys()
const index = pinned.indexOf(key)
let nowPinned: boolean
if (index !== -1) {
pinned.splice(index, 1)
nowPinned = false
} else {
pinned.push(key)
nowPinned = true
}
try {
serverStorage.set(PINNED_STORAGE_KEY, JSON.stringify(pinned))
} catch (e) {
console.warn('Failed to save pinned models:', e)
}
return nowPinned
}
/**
* 获取置顶模型列表(保持置顶顺序)
*/
export function getPinnedModels(models: ModelInfo[]): ModelInfo[] {
const pinnedKeys = getPinnedModelKeys()
if (pinnedKeys.length === 0) return []
const keySet = new Set(pinnedKeys)
const modelMap = new Map<string, ModelInfo>()
for (const m of models) {
const k = getModelKey(m)
if (keySet.has(k)) modelMap.set(k, m)
}
// 保持置顶顺序
return pinnedKeys.map(k => modelMap.get(k)).filter((m): m is ModelInfo => !!m)
}
+73
View File
@@ -0,0 +1,73 @@
// ============================================
// Notification Sound Bridge
// ============================================
//
// 连接 notificationStore 和声音播放系统
// 在应用初始化时调用 initNotificationSound() 注册即可
//
// 触发点:
// 1. notificationStore.push → 后台会话通知声音
// 2. 当前会话事件 → 由 useGlobalEvents 回调中调用 playNotificationSound
//
// 不要在其他地方直接调用 playSound,统一走这里
import type { NotificationType } from '../store/notificationStore'
import { notificationStore } from '../store/notificationStore'
import { soundStore } from '../store/soundStore'
import { playSound } from './soundPlayer'
/**
* 为指定事件类型播放通知提示音
* 会检查总开关和音量设置
*/
export function playNotificationSound(type: NotificationType): void {
const settings = soundStore.getSnapshot()
// 总开关关闭
if (!settings.enabled) return
// 音量为 0
if (settings.volume <= 0) return
const eventConfig = settings.events[type]
if (!eventConfig || eventConfig.soundId === 'none') return
const customBlob = eventConfig.soundId === 'custom' ? soundStore.getCustomAudioBlob(type) : null
playSound({
soundId: eventConfig.soundId,
customAudioData: customBlob,
volume: settings.volume,
})
}
// 去重:防止同一事件在短时间内重复播放(后台通知 + 当前会话同时触发)
const recentPlays = new Map<NotificationType, number>()
const DEDUP_INTERVAL = 500 // 500ms 内同类型事件不重复播放
/**
* 带去重的通知声音播放
* 用于当前会话播放场景,防止和后台通知重复
*/
export function playNotificationSoundDeduped(type: NotificationType): void {
const now = Date.now()
const lastPlay = recentPlays.get(type)
if (lastPlay && now - lastPlay < DEDUP_INTERVAL) return
recentPlays.set(type, now)
playNotificationSound(type)
}
/**
* 初始化通知声音系统
* 在 App 层调用一次即可,注册 notificationStore.push 的声音回调
*/
export function initNotificationSound(): () => void {
const unsubscribe = notificationStore.onPush((type: NotificationType) => {
// notificationStore.push 只在后台会话触发(非当前 session family)
// 记录播放时间用于去重
recentPlays.set(type, Date.now())
playNotificationSound(type)
})
return unsubscribe
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { formatOpenRouterPricePerMillion, selectOpenRouterModel } from './openRouterModelMetadata'
const catalog = [
{
id: 'deepseek/deepseek-v4-flash:free',
name: 'DeepSeek: DeepSeek V4 Flash (free)',
context_length: 1048576,
top_provider: { max_completion_tokens: 384000 },
architecture: { input_modalities: ['text'], output_modalities: ['text'], modality: 'text->text' },
pricing: { prompt: '0', completion: '0' },
supported_parameters: ['tools'],
},
{
id: 'deepseek/deepseek-v4-flash',
name: 'DeepSeek: DeepSeek V4 Flash',
context_length: 1048576,
top_provider: { max_completion_tokens: 131072 },
architecture: { input_modalities: ['text'], output_modalities: ['text'], modality: 'text->text' },
pricing: { prompt: '0.0000002', completion: '0.0000008' },
supported_parameters: ['tools', 'reasoning'],
},
{
id: 'openai/gpt-5.2',
name: 'OpenAI: GPT-5.2',
context_length: 400000,
top_provider: { max_completion_tokens: 128000 },
architecture: { input_modalities: ['text', 'image'], output_modalities: ['text'], modality: 'text+image->text' },
pricing: { prompt: '0.00000175', completion: '0.000014' },
supported_parameters: ['response_format', 'structured_outputs'],
},
]
describe('openRouterModelMetadata', () => {
it('matches Pi model ids against OpenRouter ids even when provider ids differ', () => {
const match = selectOpenRouterModel(catalog, {
modelID: 'deepseek-v4-flash',
providerID: 'opencode-go',
contextWindow: 1048576,
})
expect(match?.id).toBe('deepseek/deepseek-v4-flash')
})
it('prefers exact full id matches', () => {
const match = selectOpenRouterModel(catalog, {
modelID: 'gpt-5.2',
providerID: 'openai',
contextWindow: 400000,
})
expect(match?.id).toBe('openai/gpt-5.2')
})
it('formats token prices as dollars per million tokens', () => {
expect(formatOpenRouterPricePerMillion('0.00000175')).toBe('$1.75/M')
expect(formatOpenRouterPricePerMillion('0')).toBe('$0/M')
expect(formatOpenRouterPricePerMillion(undefined)).toBe('—')
})
})
+106
View File
@@ -0,0 +1,106 @@
export interface OpenRouterModelEntry {
id: string
name: string
description?: string | null
context_length?: number | null
architecture?: {
modality?: string | null
input_modalities?: string[] | null
output_modalities?: string[] | null
tokenizer?: string | null
instruct_type?: string | null
} | null
pricing?: Record<string, string | undefined> | null
top_provider?: {
context_length?: number | null
max_completion_tokens?: number | null
is_moderated?: boolean | null
} | null
supported_parameters?: string[] | null
}
interface ModelQuery {
modelID?: string
providerID?: string
modelName?: string
contextWindow?: number
}
function normalize(value: string | undefined): string {
return (value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '')
}
function providerFromOpenRouterId(id: string): string {
return id.split('/')[0] ?? ''
}
function isFreeVariant(id: string): boolean {
return id.endsWith(':free')
}
function scoreModel(entry: OpenRouterModelEntry, query: ModelQuery): number {
const modelID = query.modelID ?? ''
const providerID = query.providerID ?? ''
const fullID = providerID && modelID ? `${providerID}/${modelID}` : modelID
const normalizedEntryID = normalize(entry.id)
const normalizedModelID = normalize(modelID)
const normalizedFullID = normalize(fullID)
const normalizedEntryName = normalize(entry.name)
const normalizedModelName = normalize(query.modelName)
let score = 0
if (entry.id === fullID) score += 1000
if (entry.id === modelID) score += 900
if (normalizedEntryID === normalizedFullID) score += 700
if (normalizedEntryID === normalizedModelID) score += 650
if (modelID && entry.id.endsWith(`/${modelID}`)) score += 600
if (normalizedModelID && normalizedEntryID.endsWith(normalizedModelID)) score += 450
if (normalizedModelName && normalizedEntryName.includes(normalizedModelName)) score += 250
const normalizedProvider = normalize(providerID)
if (normalizedProvider && normalize(providerFromOpenRouterId(entry.id)) === normalizedProvider) score += 150
if (query.contextWindow && entry.context_length) {
const distance = Math.abs(entry.context_length - query.contextWindow) / query.contextWindow
score += Math.max(0, 120 - distance * 120)
}
if (isFreeVariant(entry.id)) score -= 40
return score
}
export function selectOpenRouterModel(
catalog: OpenRouterModelEntry[],
query: ModelQuery,
): OpenRouterModelEntry | undefined {
let best: { entry: OpenRouterModelEntry; score: number } | undefined
for (const entry of catalog) {
const score = scoreModel(entry, query)
if (score <= 0) continue
if (!best || score > best.score) best = { entry, score }
}
return best?.entry
}
export function formatOpenRouterPricePerMillion(value: string | undefined): string {
if (value === undefined) return '—'
const perToken = Number(value)
if (!Number.isFinite(perToken)) return '—'
const perMillion = perToken * 1_000_000
if (perMillion === 0) return '$0/M'
if (perMillion < 0.01) return '<$0.01/M'
return `$${perMillion
.toFixed(perMillion >= 10 ? 2 : 4)
.replace(/0+$/, '')
.replace(/\.$/, '')}/M`
}
export function getModelProviderLabel(entry: OpenRouterModelEntry | undefined): string | undefined {
if (!entry) return undefined
const [provider] = entry.name.split(':')
return provider?.trim() || providerFromOpenRouterId(entry.id) || undefined
}
+122
View File
@@ -0,0 +1,122 @@
// ============================================
// Per-Server Storage - 按服务器隔离的 localStorage 读写
// ============================================
//
// 不同服务器有不同的项目目录、模型、路径风格等设置。
// 这个工具函数给 localStorage key 加上 serverId 前缀,实现隔离。
//
// 用法:
// import { serverStorage } from '../utils/perServerStorage'
// serverStorage.get('last-directory') // 读取当前服务器的值
// serverStorage.set('last-directory', '/foo') // 写入当前服务器的值
// serverStorage.remove('last-directory') // 删除当前服务器的值
import { serverStore } from '../store/serverStore'
/**
* 生成带 serverId 前缀的 localStorage key
* 格式: `srv:{serverId}:{key}`
*/
function makeKey(key: string): string {
const serverId = serverStore.getActiveServerId()
return `srv:${serverId}:${key}`
}
export const serverStorage = {
/**
* 读取当前服务器的存储值
*/
get(key: string): string | null {
try {
return localStorage.getItem(makeKey(key))
} catch {
return null
}
},
/**
* 写入当前服务器的存储值
*/
set(key: string, value: string): void {
try {
localStorage.setItem(makeKey(key), value)
} catch {
// ignore
}
},
/**
* 删除当前服务器的存储值
*/
remove(key: string): void {
try {
localStorage.removeItem(makeKey(key))
} catch {
// ignore
}
},
/**
* 读取当前服务器的 JSON 值(自动解析)
*/
getJSON<T>(key: string): T | null {
const raw = serverStorage.get(key)
if (!raw) return null
try {
return JSON.parse(raw) as T
} catch {
return null
}
},
/**
* 写入当前服务器的 JSON 值(自动序列化)
*/
setJSON(key: string, value: unknown): void {
serverStorage.set(key, JSON.stringify(value))
},
}
export interface PerServerStorageBackup {
entries: Record<string, string>
}
const SERVER_STORAGE_PREFIX = 'srv:'
export function exportPerServerStorageBackup(): PerServerStorageBackup {
const entries: Record<string, string> = {}
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index)
if (!key || !key.startsWith(SERVER_STORAGE_PREFIX)) continue
const value = localStorage.getItem(key)
if (value !== null) {
entries[key] = value
}
}
return { entries }
}
export function importPerServerStorageBackup(raw: unknown): void {
const parsed = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : undefined
const entries = parsed?.entries && typeof parsed.entries === 'object' ? parsed.entries : {}
const keysToRemove: string[] = []
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index)
if (key && key.startsWith(SERVER_STORAGE_PREFIX)) {
keysToRemove.push(key)
}
}
for (const key of keysToRemove) {
localStorage.removeItem(key)
}
for (const [key, value] of Object.entries(entries as Record<string, unknown>)) {
if (key.startsWith(SERVER_STORAGE_PREFIX) && typeof value === 'string') {
localStorage.setItem(key, value)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { parsePtyFrame } from './ptyProtocol'
describe('parsePtyFrame', () => {
it('returns text frames unchanged', () => {
expect(parsePtyFrame('hello\r\n')).toEqual({ kind: 'data', data: 'hello\r\n' })
})
it('parses websocket binary control frames', () => {
const payload = new TextEncoder().encode('{"cursor":73}')
const bytes = new Uint8Array(payload.length + 1)
bytes[0] = 0
bytes.set(payload, 1)
expect(parsePtyFrame(bytes.buffer)).toEqual({ kind: 'control', cursor: 73 })
})
it('parses tauri bridge control frames with leading null byte', () => {
expect(parsePtyFrame('\0{"cursor":73}')).toEqual({ kind: 'control', cursor: 73 })
})
it('decodes binary text frames', () => {
const bytes = new TextEncoder().encode('ls\n')
expect(parsePtyFrame(bytes.buffer)).toEqual({ kind: 'data', data: 'ls\n' })
})
it('ignores malformed control frames', () => {
expect(parsePtyFrame('\0{"cursor":"oops"}')).toBeNull()
})
})
+39
View File
@@ -0,0 +1,39 @@
const decoder = new TextDecoder()
export type PtyFrame =
| { kind: 'data'; data: string }
| { kind: 'control'; cursor: number }
function parseControlPayload(payload: string): PtyFrame | null {
try {
const meta = JSON.parse(payload) as { cursor?: unknown }
const cursor = meta?.cursor
if (typeof cursor === 'number' && Number.isSafeInteger(cursor) && cursor >= 0) {
return { kind: 'control', cursor }
}
} catch {
// Ignore malformed PTY control frames.
}
return null
}
export function parsePtyFrame(chunk: string | ArrayBuffer | Uint8Array): PtyFrame | null {
if (typeof chunk === 'string') {
if (chunk.startsWith('\0')) {
return parseControlPayload(chunk.slice(1))
}
return chunk ? { kind: 'data', data: chunk } : null
}
const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)
if (bytes.length === 0) return null
if (bytes[0] === 0) {
return parseControlPayload(decoder.decode(bytes.subarray(1)))
}
const data = decoder.decode(bytes)
return data ? { kind: 'data', data } : null
}
+48
View File
@@ -0,0 +1,48 @@
// ============================================
// Session 加载相关的辅助函数
// ============================================
import type { ModelInfo } from '../api'
import { getModelKey } from './modelUtils'
// ============================================
// Model Selection 恢复
// ============================================
export interface ModelSelectionResult {
modelKey: string // providerId:modelId 格式
model: ModelInfo
variant: string | undefined
}
/**
* 根据 session 最后使用的模型信息恢复选择
*/
export function restoreModelSelection(
lastModel: { providerID: string; modelID: string } | null,
lastVariant: string | null,
models: ModelInfo[],
): ModelSelectionResult | null {
if (!lastModel || models.length === 0) {
return null
}
// 精确匹配 providerId + modelId(不做 modelID-only fallback,
// 避免多 provider 同 modelId 时选错渠道)
const model = models.find(m => m.providerId === lastModel.providerID && m.id === lastModel.modelID)
if (!model) {
return null
}
let variant: string | undefined = undefined
if (lastVariant && model.variants.includes(lastVariant)) {
variant = lastVariant
}
return {
modelKey: getModelKey(model),
model,
variant,
}
}
+67
View File
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
describe('settingsBackup', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
vi.resetModules()
})
it('exports settings as module snapshots', async () => {
localStorage.setItem('theme-preset', 'claude')
localStorage.setItem('theme-mode', 'dark')
localStorage.setItem('opencode-sidebar-expanded', 'false')
localStorage.setItem('opencode-right-panel-width', '512')
localStorage.setItem('notifications-enabled', 'true')
localStorage.setItem('opencode:toast-enabled', 'false')
localStorage.setItem('srv:local:last-directory', '/workspace/project')
localStorage.setItem('srv:local:opencode-auto-approve-enabled', 'true')
const { exportSettingsBackup } = await import('./settingsBackup')
const { data } = await exportSettingsBackup()
const backup = JSON.parse(new TextDecoder().decode(data)) as {
schemaVersion: number
modules: Record<string, unknown>
}
expect(backup.schemaVersion).toBe(2)
expect((backup.modules.theme as { presetId: string }).presetId).toBe('claude')
expect((backup.modules.layout as { rightPanelWidth: number }).rightPanelWidth).toBe(512)
expect((backup.modules.notifications as { browserNotificationsEnabled: boolean }).browserNotificationsEnabled).toBe(
true,
)
expect(
(backup.modules.perServerStorage as { entries: Record<string, string> }).entries['srv:local:last-directory'],
).toBe('/workspace/project')
})
it('restores settings from module snapshots', async () => {
localStorage.setItem('theme-preset', 'claude')
localStorage.setItem('theme-mode', 'dark')
localStorage.setItem('opencode-sidebar-expanded', 'false')
localStorage.setItem('opencode-right-panel-width', '512')
localStorage.setItem('notifications-enabled', 'true')
localStorage.setItem('opencode:toast-enabled', 'false')
localStorage.setItem('srv:local:last-directory', '/workspace/project')
localStorage.setItem('srv:local:opencode-auto-approve-enabled', 'true')
const { exportSettingsBackup, importSettingsBackup } = await import('./settingsBackup')
const { data, fileName } = await exportSettingsBackup()
const file = new File([new TextDecoder().decode(data)], fileName, { type: 'application/json' })
localStorage.clear()
sessionStorage.clear()
await importSettingsBackup(file)
expect(localStorage.getItem('theme-preset')).toBe('claude')
expect(localStorage.getItem('theme-mode')).toBe('dark')
expect(localStorage.getItem('opencode-sidebar-expanded')).toBe('false')
expect(localStorage.getItem('opencode-right-panel-width')).toBe('512')
expect(localStorage.getItem('notifications-enabled')).toBe('true')
expect(localStorage.getItem('srv:local:last-directory')).toBe('/workspace/project')
expect(localStorage.getItem('srv:local:opencode-auto-approve-enabled')).toBe('true')
expect(localStorage.getItem('opencode:toast-enabled')).toBe('false')
expect(sessionStorage.getItem('opencode-active-server')).toBe('local')
})
})
+189
View File
@@ -0,0 +1,189 @@
import { STORAGE_KEY_NOTIFICATIONS_ENABLED } from '../constants/storage'
import {
exportLayoutBackup,
exportNotificationEventSettingsBackup,
exportNotificationPreferencesBackup,
exportServerSettingsBackup,
exportServiceSettingsBackup,
exportThemeBackup,
exportUpdateSettingsBackup,
importLayoutBackup,
importNotificationEventSettingsBackup,
importNotificationPreferencesBackup,
importServerSettingsBackup,
importServiceSettingsBackup,
importThemeBackup,
importUpdateSettingsBackup,
type LayoutBackup,
type NotificationEventSettingsBackup,
type NotificationPreferencesBackup,
type ServerSettingsBackup,
type ServiceSettingsBackup,
type ThemeBackup,
type UpdateSettingsBackup,
} from '../store'
import { exportKeybindingBackup, importKeybindingBackup, type KeybindingBackup } from '../store/keybindingStore'
import { exportSoundBackup, importSoundBackup, type SoundBackup } from '../store/soundStore'
import {
exportPerServerStorageBackup,
importPerServerStorageBackup,
type PerServerStorageBackup,
} from './perServerStorage'
const BACKUP_KIND = 'settings-backup'
const BACKUP_SCHEMA_VERSION = 2
export interface NotificationBackup {
browserNotificationsEnabled: boolean
toast: NotificationPreferencesBackup
events: NotificationEventSettingsBackup
}
export interface SettingsBackupModules {
theme: ThemeBackup
layout: LayoutBackup
servers: ServerSettingsBackup
perServerStorage: PerServerStorageBackup
service: ServiceSettingsBackup
keybindings: KeybindingBackup
notifications: NotificationBackup
sound: SoundBackup
update: UpdateSettingsBackup
}
export interface SettingsBackupFile {
app: 'OpenCodeUI'
kind: typeof BACKUP_KIND
schemaVersion: typeof BACKUP_SCHEMA_VERSION
createdAt: string
modules: SettingsBackupModules
}
function exportNotificationBackup(): NotificationBackup {
return {
browserNotificationsEnabled: localStorage.getItem(STORAGE_KEY_NOTIFICATIONS_ENABLED) === 'true',
toast: exportNotificationPreferencesBackup(),
events: exportNotificationEventSettingsBackup(),
}
}
function importNotificationBackup(raw: unknown): void {
const parsed = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : undefined
const browserNotificationsEnabled = parsed?.browserNotificationsEnabled === true
if (browserNotificationsEnabled) {
localStorage.setItem(STORAGE_KEY_NOTIFICATIONS_ENABLED, 'true')
} else {
localStorage.removeItem(STORAGE_KEY_NOTIFICATIONS_ENABLED)
}
importNotificationPreferencesBackup(parsed?.toast)
importNotificationEventSettingsBackup(parsed?.events)
}
function normalizeBackupFile(raw: unknown): SettingsBackupFile {
if (!raw || typeof raw !== 'object') {
throw new Error('Invalid backup file')
}
const parsed = raw as Record<string, unknown>
if (parsed.app !== 'OpenCodeUI' || parsed.kind !== BACKUP_KIND || parsed.schemaVersion !== BACKUP_SCHEMA_VERSION) {
throw new Error('Unsupported backup format')
}
if (!parsed.modules || typeof parsed.modules !== 'object') {
throw new Error('Missing backup modules')
}
const modules = parsed.modules as Record<string, unknown>
const requiredModules: Array<keyof SettingsBackupModules> = [
'theme',
'layout',
'servers',
'perServerStorage',
'service',
'keybindings',
'notifications',
'sound',
'update',
]
for (const id of requiredModules) {
if (!(id in modules)) {
throw new Error(`Missing backup module: ${id}`)
}
}
return {
app: 'OpenCodeUI',
kind: BACKUP_KIND,
schemaVersion: BACKUP_SCHEMA_VERSION,
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : new Date().toISOString(),
modules: modules as unknown as SettingsBackupModules,
}
}
function buildBackupFileName(createdAt: string): string {
const safeTimestamp = createdAt.replace(/[:]/g, '-').replace(/\.\d+Z$/, 'Z')
return `opencodeui-settings-backup-${safeTimestamp}.json`
}
export async function exportSettingsBackup(): Promise<{ fileName: string; data: Uint8Array }> {
const createdAt = new Date().toISOString()
const backup: SettingsBackupFile = {
app: 'OpenCodeUI',
kind: BACKUP_KIND,
schemaVersion: BACKUP_SCHEMA_VERSION,
createdAt,
modules: {
theme: exportThemeBackup(),
layout: exportLayoutBackup(),
servers: exportServerSettingsBackup(),
perServerStorage: exportPerServerStorageBackup(),
service: exportServiceSettingsBackup(),
keybindings: exportKeybindingBackup(),
notifications: exportNotificationBackup(),
sound: await exportSoundBackup(),
update: exportUpdateSettingsBackup(),
},
}
return {
fileName: buildBackupFileName(createdAt),
data: new TextEncoder().encode(`${JSON.stringify(backup, null, 2)}\n`),
}
}
export async function importSettingsBackup(file: File): Promise<void> {
const text = await file.text()
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
throw new Error('Invalid backup file')
}
const backup = normalizeBackupFile(parsed)
importThemeBackup(backup.modules.theme)
importLayoutBackup(backup.modules.layout)
importServerSettingsBackup(backup.modules.servers)
importPerServerStorageBackup(backup.modules.perServerStorage)
importServiceSettingsBackup(backup.modules.service)
importKeybindingBackup(backup.modules.keybindings)
importNotificationBackup(backup.modules.notifications)
await importSoundBackup(backup.modules.sound)
importUpdateSettingsBackup(backup.modules.update)
}
export function previewBackupMeta(file: File): Promise<{ createdAt: string | null }> {
return file.text().then(text => {
try {
const parsed = JSON.parse(text) as Record<string, unknown>
return { createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : null }
} catch {
return { createdAt: null }
}
})
}
+389
View File
@@ -0,0 +1,389 @@
// ============================================
// Sound Player - 统一声音播放服务
// ============================================
//
// 职责:
// 1. 内置音效生成(Web Audio API,零资源文件)
// 2. 自定义音频播放(用户上传的音频 Blob)
// 3. 音量控制(统一映射到 0-100 滑条语义)
// 4. 试听 & 真实播放共用一套逻辑
//
// 音量语义:0 = 静音,50 = 正常基准,100 = 安全最大
import type { NotificationType } from '../store/notificationStore'
// ============================================
// 内置音效 ID
// ============================================
export const BUILTIN_SOUNDS = {
// completed
'builtin:chime': 'Chime',
'builtin:success': 'Success',
'builtin:bell': 'Bell',
// permission
'builtin:knock': 'Knock',
'builtin:alert': 'Alert',
'builtin:tap': 'Tap',
// question
'builtin:ping': 'Ping',
'builtin:bubble': 'Bubble',
'builtin:pop': 'Pop',
// error
'builtin:error': 'Error',
'builtin:buzz': 'Buzz',
'builtin:warning': 'Warning',
} as const
export type BuiltinSoundId = keyof typeof BUILTIN_SOUNDS
// 每类事件推荐的默认音效
export const DEFAULT_SOUNDS: Record<NotificationType, BuiltinSoundId> = {
completed: 'builtin:chime',
permission: 'builtin:knock',
question: 'builtin:ping',
error: 'builtin:error',
}
// 每类事件的推荐内置音效列表
export const SOUND_OPTIONS: Record<NotificationType, BuiltinSoundId[]> = {
completed: ['builtin:chime', 'builtin:success', 'builtin:bell'],
permission: ['builtin:knock', 'builtin:alert', 'builtin:tap'],
question: ['builtin:ping', 'builtin:bubble', 'builtin:pop'],
error: ['builtin:error', 'builtin:buzz', 'builtin:warning'],
}
// ============================================
// AudioContext 单例
// ============================================
let _audioCtx: AudioContext | null = null
function getAudioContext(): AudioContext | null {
if (_audioCtx && _audioCtx.state !== 'closed') return _audioCtx
try {
_audioCtx = new (
window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
)()
return _audioCtx
} catch {
return null
}
}
/** 检查当前环境是否支持声音播放 */
export function isSoundSupported(): boolean {
return (
typeof AudioContext !== 'undefined' ||
typeof (window as unknown as Record<string, unknown>).webkitAudioContext !== 'undefined'
)
}
// ============================================
// 音量映射:UI 0-100 → 实际增益
// 50 = 正常基准 (gain=0.35)
// 100 = 安全最大 (gain=0.7)
// 0 = 静音
// ============================================
function volumeToGain(volume: number): number {
if (volume <= 0) return 0
if (volume >= 100) return 0.7
if (volume <= 50) {
// 0-50 线性映射到 0-0.35
return (volume / 50) * 0.35
}
// 50-100 线性映射到 0.35-0.7
return 0.35 + ((volume - 50) / 50) * 0.35
}
// ============================================
// 内置音效生成器(Web Audio API 合成)
// ============================================
type SynthFn = (ctx: AudioContext, dest: AudioNode) => void
const synthMap: Record<BuiltinSoundId, SynthFn> = {
// ---- completed ----
'builtin:chime': (ctx, dest) => {
// 柔和的双音和弦
const now = ctx.currentTime
;[523.25, 659.25].forEach((freq, i) => {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(freq, now)
gain.gain.setValueAtTime(0, now + i * 0.05)
gain.gain.linearRampToValueAtTime(1, now + i * 0.05 + 0.04)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.5 + i * 0.05)
osc.connect(gain).connect(dest)
osc.start(now + i * 0.05)
osc.stop(now + 0.6)
})
},
'builtin:success': (ctx, dest) => {
// 上升三音
const now = ctx.currentTime
;[440, 554.37, 659.25].forEach((freq, i) => {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(freq, now + i * 0.1)
gain.gain.setValueAtTime(0, now + i * 0.1)
gain.gain.linearRampToValueAtTime(0.8, now + i * 0.1 + 0.03)
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.1 + 0.35)
osc.connect(gain).connect(dest)
osc.start(now + i * 0.1)
osc.stop(now + i * 0.1 + 0.4)
})
},
'builtin:bell': (ctx, dest) => {
// 清脆铃声
const now = ctx.currentTime
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(880, now)
osc.frequency.exponentialRampToValueAtTime(1200, now + 0.02)
osc.frequency.exponentialRampToValueAtTime(880, now + 0.08)
gain.gain.setValueAtTime(0.9, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.6)
osc.connect(gain).connect(dest)
osc.start(now)
osc.stop(now + 0.65)
},
// ---- permission ----
'builtin:knock': (ctx, dest) => {
// 两下敲击
const now = ctx.currentTime
;[0, 0.15].forEach(offset => {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'triangle'
osc.frequency.setValueAtTime(200, now + offset)
osc.frequency.exponentialRampToValueAtTime(80, now + offset + 0.1)
gain.gain.setValueAtTime(0.8, now + offset)
gain.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.15)
osc.connect(gain).connect(dest)
osc.start(now + offset)
osc.stop(now + offset + 0.2)
})
},
'builtin:alert': (ctx, dest) => {
// 紧促双音提醒
const now = ctx.currentTime
;[0, 0.12].forEach(offset => {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'square'
osc.frequency.setValueAtTime(600, now + offset)
gain.gain.setValueAtTime(0.3, now + offset)
gain.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.1)
osc.connect(gain).connect(dest)
osc.start(now + offset)
osc.stop(now + offset + 0.15)
})
},
'builtin:tap': (ctx, dest) => {
// 轻拍
const now = ctx.currentTime
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'triangle'
osc.frequency.setValueAtTime(350, now)
osc.frequency.exponentialRampToValueAtTime(150, now + 0.06)
gain.gain.setValueAtTime(0.7, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12)
osc.connect(gain).connect(dest)
osc.start(now)
osc.stop(now + 0.15)
},
// ---- question ----
'builtin:ping': (ctx, dest) => {
// 清脆 ping
const now = ctx.currentTime
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(1046.5, now)
gain.gain.setValueAtTime(0.6, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3)
osc.connect(gain).connect(dest)
osc.start(now)
osc.stop(now + 0.35)
},
'builtin:bubble': (ctx, dest) => {
// 气泡上升音
const now = ctx.currentTime
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(400, now)
osc.frequency.exponentialRampToValueAtTime(900, now + 0.15)
gain.gain.setValueAtTime(0.5, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25)
osc.connect(gain).connect(dest)
osc.start(now)
osc.stop(now + 0.3)
},
'builtin:pop': (ctx, dest) => {
// 短促 pop
const now = ctx.currentTime
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(800, now)
osc.frequency.exponentialRampToValueAtTime(400, now + 0.06)
gain.gain.setValueAtTime(0.7, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1)
osc.connect(gain).connect(dest)
osc.start(now)
osc.stop(now + 0.12)
},
// ---- error ----
'builtin:error': (ctx, dest) => {
// 下降双音错误
const now = ctx.currentTime
;[0, 0.15].forEach((offset, i) => {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sawtooth'
const freq = i === 0 ? 400 : 300
osc.frequency.setValueAtTime(freq, now + offset)
osc.frequency.exponentialRampToValueAtTime(freq * 0.6, now + offset + 0.15)
gain.gain.setValueAtTime(0.25, now + offset)
gain.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.2)
osc.connect(gain).connect(dest)
osc.start(now + offset)
osc.stop(now + offset + 0.25)
})
},
'builtin:buzz': (ctx, dest) => {
// 嗡嗡震动
const now = ctx.currentTime
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sawtooth'
osc.frequency.setValueAtTime(150, now)
gain.gain.setValueAtTime(0.3, now)
gain.gain.setValueAtTime(0.1, now + 0.05)
gain.gain.setValueAtTime(0.3, now + 0.1)
gain.gain.setValueAtTime(0.1, now + 0.15)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3)
osc.connect(gain).connect(dest)
osc.start(now)
osc.stop(now + 0.35)
},
'builtin:warning': (ctx, dest) => {
// 警告音(上下交替)
const now = ctx.currentTime
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'square'
osc.frequency.setValueAtTime(500, now)
osc.frequency.setValueAtTime(400, now + 0.1)
osc.frequency.setValueAtTime(500, now + 0.2)
gain.gain.setValueAtTime(0.2, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.35)
osc.connect(gain).connect(dest)
osc.start(now)
osc.stop(now + 0.4)
},
}
// ============================================
// 播放内置音效
// ============================================
function playBuiltinSound(soundId: BuiltinSoundId, volume: number): void {
const ctx = getAudioContext()
if (!ctx) return
// 恢复挂起的 AudioContext(浏览器 autoplay 策略)
if (ctx.state === 'suspended') {
ctx.resume().catch(() => {})
}
const masterGain = ctx.createGain()
masterGain.gain.setValueAtTime(volumeToGain(volume), ctx.currentTime)
masterGain.connect(ctx.destination)
const synth = synthMap[soundId]
if (synth) {
synth(ctx, masterGain)
}
}
// ============================================
// 播放自定义音频(Blob/URL)
// ============================================
async function playCustomSound(audioData: Blob | string, volume: number): Promise<void> {
const ctx = getAudioContext()
if (!ctx) return
if (ctx.state === 'suspended') {
await ctx.resume()
}
let arrayBuffer: ArrayBuffer
if (typeof audioData === 'string') {
// data URL 或 object URL
const response = await fetch(audioData)
arrayBuffer = await response.arrayBuffer()
} else {
arrayBuffer = await audioData.arrayBuffer()
}
const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
const source = ctx.createBufferSource()
const gainNode = ctx.createGain()
source.buffer = audioBuffer
gainNode.gain.setValueAtTime(volumeToGain(volume), ctx.currentTime)
source.connect(gainNode).connect(ctx.destination)
source.start(0)
}
// ============================================
// 公共 API
// ============================================
export interface PlaySoundOptions {
soundId: string // 'builtin:xxx' 或 'custom'
customAudioData?: Blob | null
volume: number // 0-100
}
/**
* 播放提示音(内置或自定义)
* 统一入口,试听和真实播放都走这里
*/
export function playSound(options: PlaySoundOptions): void {
const { soundId, customAudioData, volume } = options
if (volume <= 0) return
if (!isSoundSupported()) return
if (soundId === 'custom' && customAudioData) {
playCustomSound(customAudioData, volume).catch(err => {
if (import.meta.env.DEV) {
console.warn('[SoundPlayer] Failed to play custom sound:', err)
}
})
} else if (soundId.startsWith('builtin:')) {
playBuiltinSound(soundId as BuiltinSoundId, volume)
}
}
+11
View File
@@ -0,0 +1,11 @@
export function getInitials(name: string): string {
const clean = name.replace(/[-_]/g, ' ')
const parts = clean.split(' ').filter(Boolean)
if (parts.length === 0) return '?'
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] + parts[1][0]).toUpperCase()
}
// 目录路径工具已移至 directoryUtils.ts
// 保持向后兼容的 re-export
export { normalizeForComparison as normalizeDirectoryPath, isSameDirectory } from './directoryUtils'
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it, afterEach } from 'vitest'
import { isTauri, isTauriMobile, extToMime } from './tauri'
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 需要动态修改 window 属性做测试
const win = window as any
describe('isTauri', () => {
afterEach(() => {
delete win.__TAURI_INTERNALS__
})
it('returns false in browser environment (no __TAURI_INTERNALS__)', () => {
expect(isTauri()).toBe(false)
})
it('returns true when __TAURI_INTERNALS__ is present', () => {
win.__TAURI_INTERNALS__ = {}
expect(isTauri()).toBe(true)
})
})
describe('isTauriMobile', () => {
afterEach(() => {
delete win.__TAURI_INTERNALS__
})
it('returns false when not in Tauri environment', () => {
expect(isTauriMobile()).toBe(false)
})
it('returns false in Tauri desktop environment', () => {
win.__TAURI_INTERNALS__ = {}
// jsdom 默认 userAgent 不含移动端标识
expect(isTauriMobile()).toBe(false)
})
})
describe('extToMime', () => {
it('returns correct MIME for common image types', () => {
expect(extToMime('png')).toBe('image/png')
expect(extToMime('jpg')).toBe('image/jpeg')
expect(extToMime('svg')).toBe('image/svg+xml')
})
it('returns correct MIME for audio/video types', () => {
expect(extToMime('mp3')).toBe('audio/mpeg')
expect(extToMime('mp4')).toBe('video/mp4')
})
it('returns octet-stream for unknown extensions', () => {
expect(extToMime('xyz')).toBe('application/octet-stream')
expect(extToMime('foo')).toBe('application/octet-stream')
})
})
+63
View File
@@ -0,0 +1,63 @@
// ============================================
// Tauri 平台检测 & 工具
// ============================================
/**
* 检测当前是否运行在 Tauri 环境中
* 通过检查 window.__TAURI_INTERNALS__ 来判断
*/
export function isTauri(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
}
export function isTauriMobile(): boolean {
if (!isTauri() || typeof navigator === 'undefined') return false
return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)
}
export type DesktopPlatform = 'windows' | 'macos' | 'linux' | 'other'
export function getDesktopPlatform(): DesktopPlatform {
if (!isTauri() || isTauriMobile() || typeof navigator === 'undefined') return 'other'
const ua = navigator.userAgent.toLowerCase()
if (ua.includes('windows')) return 'windows'
if (ua.includes('mac os') || ua.includes('macintosh')) return 'macos'
if (ua.includes('linux')) return 'linux'
return 'other'
}
export function usesCustomDesktopTitlebar(): boolean {
const platform = getDesktopPlatform()
return platform === 'windows' || platform === 'macos'
}
/** 文件扩展名 → MIME 类型映射 */
export function extToMime(ext: string): string {
const map: Record<string, string> = {
// image
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp',
svg: 'image/svg+xml',
// pdf
pdf: 'application/pdf',
// audio
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
flac: 'audio/flac',
aac: 'audio/aac',
m4a: 'audio/mp4',
// video
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
mkv: 'video/x-matroska',
}
return map[ext] || 'application/octet-stream'
}