+ startSystemDownload(url, suggestFileName(contentDisposition, url))
+ })
+
webView.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
// 按需放行媒体权限(例如文件上传触发的媒体访问)
@@ -181,9 +203,21 @@ class MainActivity : AppCompatActivity() {
this@MainActivity.filePathCallback?.onReceiveValue(null)
this@MainActivity.filePathCallback = filePathCallback
- val intent = fileChooserParams?.createIntent() ?: Intent(Intent.ACTION_GET_CONTENT).apply {
+ // 统一使用 ACTION_GET_CONTENT,避免不同 ROM 对 Photo Picker / 相册的兼容差异
+ // 导致返回的 URI/文件对象在上传时失效。根据 acceptTypes 设置过滤类型,
+ // 图片选择仍只显示图片文件,但走文件管理器而不是系统相册。
+ val acceptTypes = fileChooserParams?.acceptTypes?.filter { it.isNotBlank() }?.takeIf { it.isNotEmpty() }
+ ?: arrayListOf("*/*")
+ val allowMultiple = fileChooserParams?.mode == FileChooserParams.MODE_OPEN_MULTIPLE
+ val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
- type = "*/*"
+ type = acceptTypes.first()
+ if (allowMultiple) {
+ putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
+ }
+ if (acceptTypes.size > 1) {
+ putExtra(Intent.EXTRA_MIME_TYPES, acceptTypes.toTypedArray())
+ }
}
fileChooserLauncher.launch(intent)
return true
@@ -384,6 +418,52 @@ class MainActivity : AppCompatActivity() {
fun isPdfPreviewSupported(): Boolean = true
}
+ inner class DownloadBridge {
+ @JavascriptInterface
+ fun downloadFile(fileUrl: String?, fileName: String?) {
+ val url = fileUrl ?: return
+ val name = fileName ?: suggestFileName(null, url)
+ runOnUiThread {
+ startSystemDownload(url, name)
+ }
+ }
+ }
+
+ private fun startSystemDownload(url: String, fileName: String) {
+ try {
+ val request = DownloadManager.Request(Uri.parse(url))
+ .setTitle(fileName)
+ .setDescription("正在下载...")
+ .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+ .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
+ .setAllowedOverRoaming(true)
+ .setAllowedOverMetered(true)
+
+ val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
+ downloadManager.enqueue(request)
+ Toast.makeText(this, "已开始下载:$fileName", Toast.LENGTH_SHORT).show()
+ } catch (e: Exception) {
+ Log.e(TAG, "下载失败: ${e.message}", e)
+ Toast.makeText(this, "下载启动失败,请重试", Toast.LENGTH_LONG).show()
+ // 兜底:尝试用浏览器打开
+ try {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
+ startActivity(intent)
+ } catch (_: Exception) {}
+ }
+ }
+
+ private fun suggestFileName(contentDisposition: String?, url: String): String {
+ contentDisposition?.let {
+ val regex = Regex("filename\\*?=\\s*\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)
+ regex.find(it)?.groupValues?.get(1)?.trim()?.takeIf { name -> name.isNotBlank() }?.let { return it }
+ }
+ val uri = Uri.parse(url)
+ uri.lastPathSegment?.takeIf { it.isNotBlank() && it.contains(".") }?.let { return it }
+ uri.getQueryParameter("path")?.split("/")?.lastOrNull()?.takeIf { it.isNotBlank() }?.let { return it }
+ return "download_${System.currentTimeMillis()}"
+ }
+
inner class ThemeBridge {
@JavascriptInterface
fun onThemeChanged(theme: String?) {
diff --git a/server/chat/files.py b/server/chat/files.py
index b85d7f9..d7faa7c 100644
--- a/server/chat/files.py
+++ b/server/chat/files.py
@@ -47,6 +47,40 @@ from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode
UPLOAD_FOLDER_NAME = ".agents/user_upload"
+
+
+def _derive_filename_from_upload(uploaded_file) -> str:
+ """当上传请求未提供文件名时,根据 MIME 类型或文件扩展名推断一个兜底文件名。"""
+ mime_type = (uploaded_file.content_type or '').strip().lower()
+ if mime_type:
+ # 优先使用 mimetypes 库的 guess_extension,它通常比硬编码映射更全面
+ ext = mimetypes.guess_extension(mime_type, strict=False)
+ if ext:
+ return f"upload_{int(time.time())}{ext}"
+ # 常见类型的兜底映射
+ type_map = {
+ 'image/jpeg': '.jpg',
+ 'image/jpg': '.jpg',
+ 'image/png': '.png',
+ 'image/gif': '.gif',
+ 'image/webp': '.webp',
+ 'image/bmp': '.bmp',
+ 'image/svg+xml': '.svg',
+ 'video/mp4': '.mp4',
+ 'video/quicktime': '.mov',
+ 'video/x-matroska': '.mkv',
+ 'video/avi': '.avi',
+ 'video/webm': '.webm',
+ 'application/pdf': '.pdf',
+ 'text/plain': '.txt',
+ 'text/markdown': '.md',
+ }
+ ext = type_map.get(mime_type)
+ if ext:
+ return f"upload_{int(time.time())}{ext}"
+ return ''
+
+
@chat_bp.route('/api/upload', methods=['POST'])
@api_login_required
@with_terminal
@@ -70,14 +104,25 @@ def upload_file(terminal: WebTerminal, workspace: UserWorkspace, username: str):
uploaded_file = request.files['file']
original_name = (request.form.get('filename') or '').strip()
- if not uploaded_file or not uploaded_file.filename or uploaded_file.filename.strip() == '':
+ if not uploaded_file:
+ return jsonify({
+ "success": False,
+ "error": "未找到文件",
+ "message": "请求中缺少文件内容"
+ }), 400
+
+ raw_name = original_name or uploaded_file.filename or ''
+ if not raw_name.strip():
+ # 文件名缺失时,尝试根据 MIME 类型或文件内容推断一个兜底文件名
+ raw_name = _derive_filename_from_upload(uploaded_file)
+
+ if not raw_name.strip():
return jsonify({
"success": False,
"error": "文件名为空",
"message": "请选择要上传的文件"
}), 400
- raw_name = original_name or uploaded_file.filename
filename = sanitize_filename_preserve_unicode(raw_name)
if not filename:
filename = secure_filename(raw_name)
diff --git a/static/src/components/chat/ShowFileCard.vue b/static/src/components/chat/ShowFileCard.vue
index de689da..e9bee6f 100644
--- a/static/src/components/chat/ShowFileCard.vue
+++ b/static/src/components/chat/ShowFileCard.vue
@@ -4,14 +4,6 @@
{{ displayName }}
-