🎉 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
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:
commit
b3489e1ede
983 files changed
+158475
No files matched your search
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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('—')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
Reference in new issue
Block a user