Skip to content

Commit d75e48c

Browse files
committed
feature
1 parent 20de004 commit d75e48c

6 files changed

Lines changed: 380 additions & 0 deletions

File tree

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## vNext
44

5+
- 新增:插件资源文件(asset)按需下载功能 —— `focusany.asset.download`(先下载到临时目录再移动到插件目录,避免中断导致文件不完整)、`asset.exists``asset.delete``asset.progress`(支持下载进度追踪),类型声明与后端实现(主进程 IPC、事件分发、进度管理)同步完成
56
- 新增:大模型设置支持模型「能力」配置与展示——预置模型补充能力标注(视觉识别 / 工具调用,覆盖 OpenAI o 系、GPT-4o、Claude、Gemini、DeepSeek、智谱 GLM-4/4V、通义千问、Yi 等 117 个模型),模型列表与下拉选择均显示能力图标
67
- 新增:添加/编辑模型弹窗的能力设置改为「能力」分组勾选(视觉识别 / 工具调用,带图标标识),与供应商设置弹窗交互保持一致
78
- 新增:后端大模型接口(`llmChat` / `llmChatJson`)支持视觉多模态消息(messages content 带 `image_url` 图片)与工具调用(`tools` / `toolChoice` 参数),返回结果支持 `toolCalls` 函数调用解析;`llmListModels` 接口返回模型能力标识 `modelCaps`
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import path from 'path'
2+
import fs from 'fs'
3+
import { Files } from '../../file/main'
4+
import { PluginContext } from '../type'
5+
6+
type AssetProgress = {
7+
total: number
8+
completed: number
9+
percent: number
10+
speed: number
11+
status: 'downloading' | 'completed' | 'failed'
12+
error?: string
13+
}
14+
15+
const downloadProgressMap = new Map<string, AssetProgress>()
16+
17+
const getKey = (pluginName: string, pluginFilePath: string) => `${pluginName}::${pluginFilePath}`
18+
19+
const getPluginRoot = (context: PluginContext): string | null => {
20+
if (context._plugin.runtime && context._plugin.runtime.root) {
21+
return context._plugin.runtime.root
22+
}
23+
return null
24+
}
25+
26+
export const assetDownload = async (context: PluginContext, data: any): Promise<void> => {
27+
const { url, pluginFilePath, options } = data
28+
const timeout = (options?.timeout ?? 3600) * 1000
29+
30+
const pluginRoot = getPluginRoot(context)
31+
if (!pluginRoot) {
32+
throw new Error('Plugin root not found')
33+
}
34+
35+
const key = getKey(context._plugin.name, pluginFilePath)
36+
const targetPath = path.join(pluginRoot, pluginFilePath)
37+
const targetDir = path.dirname(targetPath)
38+
39+
// Ensure target directory exists
40+
if (!fs.existsSync(targetDir)) {
41+
fs.mkdirSync(targetDir, { recursive: true })
42+
}
43+
44+
// Download to temp file first, then move to final location
45+
const ext = path.extname(pluginFilePath) || 'tmp'
46+
const tempFile = await Files.temp(ext, 'asset-download')
47+
48+
const progress: AssetProgress = {
49+
total: 0,
50+
completed: 0,
51+
percent: 0,
52+
speed: 0,
53+
status: 'downloading',
54+
}
55+
downloadProgressMap.set(key, progress)
56+
57+
const controller = new AbortController()
58+
const timeoutId = setTimeout(() => controller.abort(), timeout)
59+
60+
try {
61+
const res = await fetch(url, {
62+
method: 'GET',
63+
headers: { 'User-Agent': 'FocusAny' },
64+
signal: controller.signal,
65+
})
66+
67+
if (!res.ok) {
68+
throw new Error(`DownloadError:${url}`)
69+
}
70+
71+
const contentLength = res.headers.get('content-length')
72+
const totalSize = contentLength ? parseInt(contentLength, 10) : 0
73+
progress.total = totalSize
74+
75+
const reader = res.body!.getReader()
76+
const fileStream = fs.createWriteStream(tempFile)
77+
78+
let lastCompleted = 0
79+
let lastTime = Date.now()
80+
81+
const pump = async () => {
82+
while (true) {
83+
const { done, value } = await reader.read()
84+
if (done) break
85+
86+
fileStream.write(value)
87+
88+
const now = Date.now()
89+
progress.completed += value.length
90+
if (totalSize > 0) {
91+
progress.percent = Math.round((progress.completed / totalSize) * 100)
92+
}
93+
const elapsed = (now - lastTime) / 1000
94+
if (elapsed > 0.1) {
95+
progress.speed = Math.round((progress.completed - lastCompleted) / elapsed)
96+
lastCompleted = progress.completed
97+
lastTime = now
98+
}
99+
}
100+
}
101+
102+
await pump()
103+
clearTimeout(timeoutId)
104+
105+
await new Promise<void>((resolve, reject) => {
106+
fileStream.end((err) => {
107+
if (err) {
108+
reject(err)
109+
return
110+
}
111+
// Move temp file to final plugin path
112+
if (fs.existsSync(targetPath)) {
113+
fs.unlinkSync(targetPath)
114+
}
115+
fs.renameSync(tempFile, targetPath)
116+
117+
progress.status = 'completed'
118+
progress.percent = 100
119+
progress.completed = progress.total
120+
resolve()
121+
})
122+
})
123+
} catch (e: any) {
124+
clearTimeout(timeoutId)
125+
progress.status = 'failed'
126+
progress.error = e.message || '' + e
127+
128+
// Clean up temp file on failure
129+
if (fs.existsSync(tempFile)) {
130+
try {
131+
fs.unlinkSync(tempFile)
132+
} catch (_) {
133+
// ignore cleanup error
134+
}
135+
}
136+
137+
if (e.name === 'AbortError') {
138+
throw new Error('Download timeout')
139+
}
140+
throw e
141+
}
142+
}
143+
144+
export const assetExists = async (context: PluginContext, data: any): Promise<boolean> => {
145+
const { pluginFilePath } = data
146+
const pluginRoot = getPluginRoot(context)
147+
if (!pluginRoot) return false
148+
149+
const targetPath = path.join(pluginRoot, pluginFilePath)
150+
return await Files.exists(targetPath, { isDataPath: false })
151+
}
152+
153+
export const assetDelete = async (context: PluginContext, data: any): Promise<void> => {
154+
const { pluginFilePath } = data
155+
const pluginRoot = getPluginRoot(context)
156+
if (!pluginRoot) return
157+
158+
const targetPath = path.join(pluginRoot, pluginFilePath)
159+
await Files.deletes(targetPath, { isDataPath: false })
160+
161+
// Clear progress tracking
162+
const key = getKey(context._plugin.name, pluginFilePath)
163+
downloadProgressMap.delete(key)
164+
}
165+
166+
export const assetProgress = async (context: PluginContext, data: any): Promise<AssetProgress | null> => {
167+
const { pluginFilePath } = data
168+
const key = getKey(context._plugin.name, pluginFilePath)
169+
const progress = downloadProgressMap.get(key)
170+
if (!progress) return null
171+
172+
return {
173+
total: progress.total,
174+
completed: progress.completed,
175+
percent: progress.percent,
176+
speed: progress.speed,
177+
status: progress.status,
178+
error: progress.error,
179+
}
180+
}

electron/mapi/manager/plugin/event.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { listModels, modelChat, modelChatJson } from './llm'
3131
import { PluginLog } from './log'
3232
import { ManagerPluginPermission } from './permission'
3333
import { screenCapture } from './screenCapture'
34+
import { assetDownload, assetExists, assetDelete, assetProgress } from './asset'
3435

3536
const getHeadHeight = (win: BrowserWindow) => {
3637
if (win === AppRuntime.mainWindow) {
@@ -916,6 +917,32 @@ export const ManagerPluginEvent = {
916917
return tempPath
917918
},
918919

920+
// asset
921+
assetDownload: async (context: PluginContext, data: any): Promise<void> => {
922+
if (!ManagerPluginPermission.check(context._plugin, 'basic', 'File')) {
923+
return
924+
}
925+
return await assetDownload(context, data)
926+
},
927+
assetExists: async (context: PluginContext, data: any): Promise<boolean> => {
928+
if (!ManagerPluginPermission.check(context._plugin, 'basic', 'File')) {
929+
return false
930+
}
931+
return await assetExists(context, data)
932+
},
933+
assetDelete: async (context: PluginContext, data: any): Promise<void> => {
934+
if (!ManagerPluginPermission.check(context._plugin, 'basic', 'File')) {
935+
return
936+
}
937+
return await assetDelete(context, data)
938+
},
939+
assetProgress: async (context: PluginContext, data: any) => {
940+
if (!ManagerPluginPermission.check(context._plugin, 'basic', 'File')) {
941+
return null
942+
}
943+
return await assetProgress(context, data)
944+
},
945+
919946
// db
920947
dbPut: async (context: PluginContext, data: any) => {
921948
return await KVDBMain.put(context._plugin.name, data.doc)

electron/preload/focusany.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,21 @@ export const FocusAny = {
746746
},
747747
},
748748

749+
asset: {
750+
download(url: string, pluginFilePath: string, options?: { timeout?: number }): Promise<void> {
751+
return ipcSendAsync('assetDownload', { url, pluginFilePath, options })
752+
},
753+
exists(pluginFilePath: string): Promise<boolean> {
754+
return ipcSendAsync('assetExists', { pluginFilePath })
755+
},
756+
delete(pluginFilePath: string): Promise<void> {
757+
return ipcSendAsync('assetDelete', { pluginFilePath })
758+
},
759+
progress(pluginFilePath: string): Promise<AssetProgressResult | null> {
760+
return ipcSendAsync('assetProgress', { pluginFilePath })
761+
},
762+
},
763+
749764
db: {
750765
put(doc: DbDoc) {
751766
return ipcSendSync('dbPut', { doc })

sdk/focusany.d.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,28 @@ declare type LlmChatCallInfo = {
9292

9393
declare type PlatformType = "win" | "osx" | "linux";
9494

95+
/** 插件文件资源下载进度 */
96+
declare type AssetProgressResult = {
97+
/** 下载总字节数 */
98+
total: number;
99+
/** 已下载字节数 */
100+
completed: number;
101+
/** 下载进度 0~100 */
102+
percent: number;
103+
/** 下载速度 bytes/s */
104+
speed: number;
105+
/** 下载状态:downloading / completed / failed */
106+
status: "downloading" | "completed" | "failed";
107+
/** 错误信息(status=failed 时存在) */
108+
error?: string;
109+
};
110+
111+
/** 插件文件资源下载选项 */
112+
declare type AssetDownloadOptions = {
113+
/** 下载超时时间(秒),默认 3600 */
114+
timeout?: number;
115+
};
116+
95117
declare type EditionType = "open" | "pro";
96118

97119
declare type PluginEvent = "ClipboardChange" | "UserChange";
@@ -1031,6 +1053,43 @@ interface FocusAnyApi {
10311053
): Promise<string>;
10321054
};
10331055

1056+
/**
1057+
* Plugin asset resource management
1058+
*
1059+
* Download, check, delete and track progress of large asset files
1060+
* that are downloaded on-demand rather than bundled with the plugin.
1061+
*/
1062+
asset: {
1063+
/**
1064+
* Download a remote asset file to the plugin directory.
1065+
* Downloads to a temp directory first, then moves to the final location
1066+
* to avoid incomplete files on interruption.
1067+
* @param url Remote asset URL
1068+
* @param pluginFilePath Relative path within the plugin directory
1069+
* @param options Download options
1070+
*/
1071+
download(
1072+
url: string,
1073+
pluginFilePath: string,
1074+
options?: AssetDownloadOptions
1075+
): Promise<void>;
1076+
/**
1077+
* Check if an asset file exists in the plugin directory
1078+
* @param pluginFilePath Relative path within the plugin directory
1079+
*/
1080+
exists(pluginFilePath: string): Promise<boolean>;
1081+
/**
1082+
* Delete an asset file from the plugin directory
1083+
* @param pluginFilePath Relative path within the plugin directory
1084+
*/
1085+
delete(pluginFilePath: string): Promise<void>;
1086+
/**
1087+
* Get download progress of an asset file
1088+
* @param pluginFilePath Relative path within the plugin directory
1089+
*/
1090+
progress(pluginFilePath: string): Promise<AssetProgressResult | null>;
1091+
};
1092+
10341093
/**
10351094
* Database operations
10361095
*/

0 commit comments

Comments
 (0)