Compare commits
No commits in common. "e4a663594d8952b3a0423a72f43e10d852697b97" and "e90610ed050afe0d282ba19cbaac9d823e6f2678" have entirely different histories.
e4a663594d
...
e90610ed05
@ -1,8 +1,5 @@
|
|||||||
# App 更新说明
|
# App 更新说明
|
||||||
|
|
||||||
# 1.0.35
|
|
||||||
- 新增 PDF 文件原生预览:集成 AndroidPdfViewer,点击卡片「预览」按钮可在 App 内直接查看 PDF,不再依赖浏览器
|
|
||||||
|
|
||||||
# 1.0.32
|
# 1.0.32
|
||||||
- 识别引擎改为预初始化:模型下载完成后立即在后台加载,用户点击时直接可用(不再每次点击等3-5秒初始化)
|
- 识别引擎改为预初始化:模型下载完成后立即在后台加载,用户点击时直接可用(不再每次点击等3-5秒初始化)
|
||||||
- startRecordingInternal 增加详细日志,便于排查录音问题
|
- startRecordingInternal 增加详细日志,便于排查录音问题
|
||||||
|
|||||||
@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.cyjai.agent"
|
applicationId = "com.cyjai.agent"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 37
|
versionCode = 36
|
||||||
versionName = "1.0.35"
|
versionName = "1.0.34"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@ -66,9 +66,6 @@ dependencies {
|
|||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||||
|
|
||||||
// PDF 预览(不依赖浏览器)
|
|
||||||
implementation("com.github.mhiew:android-pdf-viewer:3.2.0-beta.3")
|
|
||||||
|
|
||||||
// sherpa-onnx 语音识别库(需手动下载 AAR 放到 app/libs/)
|
// sherpa-onnx 语音识别库(需手动下载 AAR 放到 app/libs/)
|
||||||
// 下载地址:https://huggingface.co/csukuangfj/sherpa-onnx-libs/tree/main/android/aar
|
// 下载地址:https://huggingface.co/csukuangfj/sherpa-onnx-libs/tree/main/android/aar
|
||||||
// 下载最新 sherpa-onnx-*.aar 放到 app/libs/ 目录即可
|
// 下载最新 sherpa-onnx-*.aar 放到 app/libs/ 目录即可
|
||||||
|
|||||||
@ -27,12 +27,6 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".PdfPreviewActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:parentActivityName=".MainActivity"
|
|
||||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|uiMode" />
|
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import android.os.Bundle
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.content.Context
|
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
import android.webkit.PermissionRequest
|
import android.webkit.PermissionRequest
|
||||||
@ -101,7 +100,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
webView.clearCache(true)
|
webView.clearCache(true)
|
||||||
|
|
||||||
webView.addJavascriptInterface(ThemeBridge(), "AndroidThemeBridge")
|
webView.addJavascriptInterface(ThemeBridge(), "AndroidThemeBridge")
|
||||||
webView.addJavascriptInterface(PdfPreviewBridge(), "AndroidPdfBridge")
|
|
||||||
|
|
||||||
// 语音识别桥接(立即注册,前端始终可检测到;引擎在首次使用时懒初始化)
|
// 语音识别桥接(立即注册,前端始终可检测到;引擎在首次使用时懒初始化)
|
||||||
voiceBridge = VoiceBridge(this@MainActivity, webView)
|
voiceBridge = VoiceBridge(this@MainActivity, webView)
|
||||||
@ -369,21 +367,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
webView.evaluateJavascript(script, null)
|
webView.evaluateJavascript(script, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class PdfPreviewBridge {
|
|
||||||
@JavascriptInterface
|
|
||||||
fun previewPdf(pdfUrl: String?) {
|
|
||||||
val url = pdfUrl ?: return
|
|
||||||
runOnUiThread {
|
|
||||||
val intent = Intent(this@MainActivity, PdfPreviewActivity::class.java)
|
|
||||||
intent.putExtra(PdfPreviewActivity.EXTRA_PDF_URL, url)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun isPdfPreviewSupported(): Boolean = true
|
|
||||||
}
|
|
||||||
|
|
||||||
inner class ThemeBridge {
|
inner class ThemeBridge {
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun onThemeChanged(theme: String?) {
|
fun onThemeChanged(theme: String?) {
|
||||||
|
|||||||
@ -1,150 +0,0 @@
|
|||||||
package com.cyjai.agent
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.ProgressBar
|
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import com.github.barteksc.pdfviewer.PDFView
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
|
|
||||||
class PdfPreviewActivity : AppCompatActivity() {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val EXTRA_PDF_URL = "pdf_url"
|
|
||||||
private const val HOME_URL = "https://agent.cyjai.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
private lateinit var pdfView: PDFView
|
|
||||||
private lateinit var loadingContainer: LinearLayout
|
|
||||||
private lateinit var progressBar: ProgressBar
|
|
||||||
private lateinit var statusText: TextView
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(R.layout.activity_pdf_preview)
|
|
||||||
|
|
||||||
supportActionBar?.apply {
|
|
||||||
setDisplayHomeAsUpEnabled(true)
|
|
||||||
title = "PDF 预览"
|
|
||||||
}
|
|
||||||
|
|
||||||
pdfView = findViewById(R.id.pdfView)
|
|
||||||
loadingContainer = findViewById(R.id.loadingContainer)
|
|
||||||
progressBar = findViewById(R.id.progressBar)
|
|
||||||
statusText = findViewById(R.id.statusText)
|
|
||||||
|
|
||||||
val rawUrl = intent.getStringExtra(EXTRA_PDF_URL) ?: ""
|
|
||||||
if (rawUrl.isBlank()) {
|
|
||||||
showError("PDF 地址为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val url = if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) {
|
|
||||||
rawUrl
|
|
||||||
} else {
|
|
||||||
HOME_URL.trimEnd('/') + (if (rawUrl.startsWith("/")) rawUrl else "/$rawUrl")
|
|
||||||
}
|
|
||||||
|
|
||||||
loadPdf(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showLoading(msg: String) {
|
|
||||||
loadingContainer.visibility = View.VISIBLE
|
|
||||||
progressBar.visibility = View.VISIBLE
|
|
||||||
statusText.visibility = View.VISIBLE
|
|
||||||
statusText.text = msg
|
|
||||||
pdfView.visibility = View.INVISIBLE
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hideLoading() {
|
|
||||||
loadingContainer.visibility = View.GONE
|
|
||||||
progressBar.visibility = View.GONE
|
|
||||||
statusText.visibility = View.GONE
|
|
||||||
pdfView.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showError(msg: String) {
|
|
||||||
loadingContainer.visibility = View.VISIBLE
|
|
||||||
progressBar.visibility = View.GONE
|
|
||||||
statusText.visibility = View.VISIBLE
|
|
||||||
statusText.text = msg
|
|
||||||
pdfView.visibility = View.INVISIBLE
|
|
||||||
Toast.makeText(this, msg, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadPdf(url: String) {
|
|
||||||
showLoading("加载中...")
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val file = downloadToCache(url)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
showPdf(file)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
showError("PDF 加载失败: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun downloadToCache(url: String): File {
|
|
||||||
val conn = URL(url).openConnection() as HttpURLConnection
|
|
||||||
conn.connectTimeout = 30_000
|
|
||||||
conn.readTimeout = 60_000
|
|
||||||
conn.setRequestProperty("Accept", "application/pdf,*/*")
|
|
||||||
conn.connect()
|
|
||||||
|
|
||||||
if (conn.responseCode !in 200..299) {
|
|
||||||
throw RuntimeException("HTTP ${conn.responseCode}")
|
|
||||||
}
|
|
||||||
|
|
||||||
val pathParam = Uri.parse(url).getQueryParameter("path")
|
|
||||||
val fileName = if (!pathParam.isNullOrBlank()) {
|
|
||||||
"preview_" + pathParam.replace("/", "_")
|
|
||||||
} else {
|
|
||||||
"preview_${System.currentTimeMillis()}.pdf"
|
|
||||||
}
|
|
||||||
val file = File(cacheDir, fileName)
|
|
||||||
|
|
||||||
FileOutputStream(file).use { out ->
|
|
||||||
conn.inputStream.use { input ->
|
|
||||||
input.copyTo(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
conn.disconnect()
|
|
||||||
return file
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showPdf(file: File) {
|
|
||||||
pdfView.fromFile(file)
|
|
||||||
.enableSwipe(true)
|
|
||||||
.swipeHorizontal(false)
|
|
||||||
.enableDoubletap(true)
|
|
||||||
.defaultPage(0)
|
|
||||||
.onError { showError("PDF 渲染失败") }
|
|
||||||
.onLoad { hideLoading() }
|
|
||||||
.load()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
|
||||||
return if (item.itemId == android.R.id.home) {
|
|
||||||
finish()
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
super.onOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent">
|
|
||||||
|
|
||||||
<com.github.barteksc.pdfviewer.PDFView
|
|
||||||
android:id="@+id/pdfView"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:visibility="invisible" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/loadingContainer"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
android:gravity="center"
|
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
android:id="@+id/progressBar"
|
|
||||||
style="?android:attr/progressBarStyleLarge"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/statusText"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:padding="16dp"
|
|
||||||
android:textColor="?android:attr/textColorPrimary" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</FrameLayout>
|
|
||||||
@ -82,30 +82,6 @@
|
|||||||
|
|
||||||
**图片检索流程**:Wikimedia Commons 优先 → `web_search` 全网搜索 → 校验匹配 → 用 `<show_image>` 作为图片卡片展示
|
**图片检索流程**:Wikimedia Commons 优先 → `web_search` 全网搜索 → 校验匹配 → 用 `<show_image>` 作为图片卡片展示
|
||||||
|
|
||||||
### 3.1.5 文件下载链接与文件预览卡片
|
|
||||||
|
|
||||||
当需要让用户下载或预览工作区内的文件时,有两种格式可用:
|
|
||||||
|
|
||||||
#### 1. 下载链接
|
|
||||||
|
|
||||||
[报告.pdf](download://output/report.pdf)
|
|
||||||
|
|
||||||
`href` 以 `download://` 开头,后跟工作区相对路径(如 `output/report.pdf`)。
|
|
||||||
|
|
||||||
#### 2. 文件预览卡片
|
|
||||||
|
|
||||||
<show_file path="output/report.pdf">
|
|
||||||
</show_file>
|
|
||||||
|
|
||||||
只写 `path` 属性,路径为工作区相对路径。前端会自动按扩展名选择预览方式(文本/图片/PDF),并在卡片内直接展示内容。
|
|
||||||
|
|
||||||
#### 使用场景判断
|
|
||||||
- 用户说「给我这个文件」「下载 xxx」→ 用下载链接
|
|
||||||
- 用户说「看一下这个文件」「展示 xxx 的内容」→ 用 `<show_file>` 卡片
|
|
||||||
- 生成报告/数据文件后想展示成果 → 用 `<show_file>` 卡片
|
|
||||||
|
|
||||||
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
|
||||||
|
|
||||||
### 3.2 文件操作
|
### 3.2 文件操作
|
||||||
|
|
||||||
- `write_file`:写入文件(`append` 控制覆盖/追加)
|
- `write_file`:写入文件(`append` 控制覆盖/追加)
|
||||||
|
|||||||
@ -82,30 +82,6 @@
|
|||||||
|
|
||||||
**图片检索流程**:Wikimedia Commons 优先 → `web_search` 全网搜索 → 校验匹配 → 用 `<show_image>` 作为图片卡片展示
|
**图片检索流程**:Wikimedia Commons 优先 → `web_search` 全网搜索 → 校验匹配 → 用 `<show_image>` 作为图片卡片展示
|
||||||
|
|
||||||
### 3.1.5 文件下载链接与文件预览卡片
|
|
||||||
|
|
||||||
当需要让用户下载或预览工作区内的文件时,有两种格式可用:
|
|
||||||
|
|
||||||
#### 1. 下载链接
|
|
||||||
|
|
||||||
[报告.pdf](download://output/report.pdf)
|
|
||||||
|
|
||||||
`href` 以 `download://` 开头,后跟工作区相对路径(如 `output/report.pdf`)。
|
|
||||||
|
|
||||||
#### 2. 文件预览卡片
|
|
||||||
|
|
||||||
<show_file path="output/report.pdf">
|
|
||||||
</show_file>
|
|
||||||
|
|
||||||
只写 `path` 属性,路径为工作区相对路径。前端会自动按扩展名选择预览方式(文本/图片/PDF),并在卡片内直接展示内容。
|
|
||||||
|
|
||||||
#### 使用场景判断
|
|
||||||
- 用户说「给我这个文件」「下载 xxx」→ 用下载链接
|
|
||||||
- 用户说「看一下这个文件」「展示 xxx 的内容」→ 用 `<show_file>` 卡片
|
|
||||||
- 生成报告/数据文件后想展示成果 → 用 `<show_file>` 卡片
|
|
||||||
|
|
||||||
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
|
||||||
|
|
||||||
### 3.2 文件操作
|
### 3.2 文件操作
|
||||||
|
|
||||||
- `write_file`:写入文件(`append` 控制覆盖/追加)
|
- `write_file`:写入文件(`append` 控制覆盖/追加)
|
||||||
|
|||||||
@ -12,7 +12,6 @@ from flask import Blueprint, jsonify, request, session, send_file
|
|||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
from werkzeug.exceptions import RequestEntityTooLarge
|
from werkzeug.exceptions import RequestEntityTooLarge
|
||||||
import secrets
|
import secrets
|
||||||
import mimetypes
|
|
||||||
|
|
||||||
from config import THINKING_FAST_INTERVAL, MAX_UPLOAD_SIZE, OUTPUT_FORMATS
|
from config import THINKING_FAST_INTERVAL, MAX_UPLOAD_SIZE, OUTPUT_FORMATS
|
||||||
from modules.personalization_manager import (
|
from modules.personalization_manager import (
|
||||||
@ -243,73 +242,3 @@ def download_folder_api(terminal: WebTerminal, workspace: UserWorkspace, usernam
|
|||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
download_name=f"{folder_name}.zip"
|
download_name=f"{folder_name}.zip"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# 文件预览允许 inline 展示的 MIME 前缀白名单
|
|
||||||
# 任何不在此白名单内的类型一律退化为 application/octet-stream(浏览器会触发下载)
|
|
||||||
_FILE_CONTENT_INLINE_MIME_PREFIXES = (
|
|
||||||
'text/',
|
|
||||||
'image/',
|
|
||||||
'application/pdf',
|
|
||||||
'application/json',
|
|
||||||
'application/javascript',
|
|
||||||
'application/xml',
|
|
||||||
)
|
|
||||||
|
|
||||||
# 允许 inline 预览的文本类扩展名(mimetypes 未识别时兑底)
|
|
||||||
_FILE_CONTENT_INLINE_TEXT_EXTS = {
|
|
||||||
'.txt', '.md', '.markdown', '.csv', '.json', '.json5', '.yaml', '.yml',
|
|
||||||
'.js', '.ts', '.jsx', '.tsx', '.vue', '.py', '.rb', '.go', '.rs',
|
|
||||||
'.java', '.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.swift', '.kt',
|
|
||||||
'.sh', '.bash', '.zsh', '.ps1', '.bat', '.cmd',
|
|
||||||
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
|
||||||
'.xml', '.svg', '.toml', '.ini', '.cfg', '.conf', '.log', '.sql',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_inline_mime(full_path: Path) -> str:
|
|
||||||
"""猜测 inline 预览用的 MIME 类型,不在白名单内的退化为 octet-stream。"""
|
|
||||||
guessed, _ = mimetypes.guess_type(str(full_path))
|
|
||||||
if guessed:
|
|
||||||
if guessed.startswith(_FILE_CONTENT_INLINE_MIME_PREFIXES):
|
|
||||||
return guessed
|
|
||||||
return 'application/octet-stream'
|
|
||||||
# mimetypes 未识别,按扩展名兑底
|
|
||||||
suffix = full_path.suffix.lower()
|
|
||||||
if suffix in _FILE_CONTENT_INLINE_TEXT_EXTS:
|
|
||||||
return 'text/plain; charset=utf-8'
|
|
||||||
if suffix == '.pdf':
|
|
||||||
return 'application/pdf'
|
|
||||||
return 'application/octet-stream'
|
|
||||||
|
|
||||||
|
|
||||||
@chat_bp.route('/api/file/content')
|
|
||||||
@api_login_required
|
|
||||||
@with_terminal
|
|
||||||
def file_content_api(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
|
||||||
"""以 inline 方式返回文件内容,用于前端预览(区别于 /api/download/file 的 attachment)。
|
|
||||||
|
|
||||||
路径校验复用 _validate_path,与下载接口同等安全级别。
|
|
||||||
非白名单 MIME 退化为 octet-stream,避免浏览器误执行 HTML/JS。"""
|
|
||||||
path = (request.args.get('path') or '').strip()
|
|
||||||
if not path:
|
|
||||||
return jsonify({"success": False, "error": "缺少路径参数"}), 400
|
|
||||||
|
|
||||||
valid, error, full_path = terminal.file_manager._validate_path(path)
|
|
||||||
if not valid or full_path is None:
|
|
||||||
return jsonify({"success": False, "error": error or "路径校验失败"}), 400
|
|
||||||
if not full_path.exists() or not full_path.is_file():
|
|
||||||
return jsonify({"success": False, "error": "文件不存在"}), 404
|
|
||||||
|
|
||||||
mime_type = _resolve_inline_mime(full_path)
|
|
||||||
|
|
||||||
# 永远不要让浏览器 inline 执行 HTML/JS(防 XSS);
|
|
||||||
# HTML 文件强制走下载,不 inline 预览
|
|
||||||
if mime_type.startswith('text/html'):
|
|
||||||
mime_type = 'application/octet-stream'
|
|
||||||
|
|
||||||
return send_file(
|
|
||||||
full_path,
|
|
||||||
mimetype=mime_type,
|
|
||||||
as_attachment=False
|
|
||||||
)
|
|
||||||
|
|||||||
@ -46,7 +46,6 @@ from modules.personalization_manager import (
|
|||||||
THINKING_INTERVAL_MIN,
|
THINKING_INTERVAL_MIN,
|
||||||
THINKING_INTERVAL_MAX,
|
THINKING_INTERVAL_MAX,
|
||||||
)
|
)
|
||||||
from modules.sub_agent.state import TERMINAL_STATUSES as SUB_AGENT_TERMINAL_STATUSES
|
|
||||||
from modules.upload_security import UploadSecurityError
|
from modules.upload_security import UploadSecurityError
|
||||||
from modules.user_manager import UserWorkspace
|
from modules.user_manager import UserWorkspace
|
||||||
from modules.usage_tracker import QUOTA_DEFAULTS
|
from modules.usage_tracker import QUOTA_DEFAULTS
|
||||||
@ -181,83 +180,20 @@ def process_message_task(terminal: WebTerminal, message: str, images, sender, cl
|
|||||||
|
|
||||||
entry = get_stop_flag(client_sid, username)
|
entry = get_stop_flag(client_sid, username)
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
entry = {'stop': False, 'task': None, 'terminal': None, 'loop': None}
|
entry = {'stop': False, 'task': None, 'terminal': None}
|
||||||
entry['stop'] = False
|
entry['stop'] = False
|
||||||
entry['task'] = task
|
entry['task'] = task
|
||||||
entry['terminal'] = terminal
|
entry['terminal'] = terminal
|
||||||
entry['loop'] = loop
|
|
||||||
set_stop_flag(client_sid, username, entry)
|
set_stop_flag(client_sid, username, entry)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loop.run_until_complete(task)
|
loop.run_until_complete(task)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
debug_log(f"[ChatFlow] 任务被成功取消: client_sid={client_sid}")
|
debug_log(f"任务 {client_sid} 被成功取消")
|
||||||
# 检测是否仍有后台任务在跑,通知前端保持停止按钮
|
sender('task_stopped', {
|
||||||
has_running_sub_agents = False
|
'message': '任务已停止',
|
||||||
has_running_background_commands = False
|
'reason': 'user_requested'
|
||||||
conversation_id = getattr(getattr(terminal, 'context_manager', None), 'current_conversation_id', None)
|
})
|
||||||
if conversation_id:
|
|
||||||
sub_agent_manager = getattr(terminal, 'sub_agent_manager', None)
|
|
||||||
if sub_agent_manager:
|
|
||||||
try:
|
|
||||||
sub_agent_manager.reconcile_task_states(conversation_id=conversation_id)
|
|
||||||
for task_info in sub_agent_manager.tasks.values():
|
|
||||||
if task_info.get('conversation_id') != conversation_id:
|
|
||||||
continue
|
|
||||||
status = task_info.get('status')
|
|
||||||
if status not in SUB_AGENT_TERMINAL_STATUSES.union({"terminated"}):
|
|
||||||
has_running_sub_agents = True
|
|
||||||
break
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log(f"[Task] 取消时检查后台子智能体失败: {exc}")
|
|
||||||
bg_manager = getattr(terminal, 'background_command_manager', None)
|
|
||||||
if bg_manager:
|
|
||||||
try:
|
|
||||||
bg_manager.reconcile_stale_records(conversation_id=conversation_id)
|
|
||||||
waiting_items = bg_manager.list_waiting_items(conversation_id)
|
|
||||||
if waiting_items:
|
|
||||||
has_running_background_commands = True
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log(f"[Task] 取消时检查后台命令失败: {exc}")
|
|
||||||
debug_log(
|
|
||||||
f"[ChatFlow] 任务取消,最终停止事件由 _run_chat_task 发送: client_sid={client_sid}, "
|
|
||||||
f"has_running_sub_agents={has_running_sub_agents}, "
|
|
||||||
f"has_running_background_commands={has_running_background_commands}"
|
|
||||||
)
|
|
||||||
# 取消时立即把当前对话最后一条 working 的用户消息 work_timer 标记为完成,
|
|
||||||
# 避免刷新页面后仍显示"工作中"。
|
|
||||||
try:
|
|
||||||
if terminal and getattr(terminal, "context_manager", None):
|
|
||||||
cm = terminal.context_manager
|
|
||||||
history = getattr(cm, "conversation_history", None) or []
|
|
||||||
for msg in reversed(history):
|
|
||||||
if not isinstance(msg, dict) or msg.get("role") != "user":
|
|
||||||
continue
|
|
||||||
metadata = msg.get("metadata") or {}
|
|
||||||
timer = metadata.get("work_timer")
|
|
||||||
if not isinstance(timer, dict) or timer.get("status") != "working":
|
|
||||||
continue
|
|
||||||
started_at = timer.get("started_at") or msg.get("timestamp") or datetime.now().isoformat()
|
|
||||||
try:
|
|
||||||
start_ts = datetime.fromisoformat(str(started_at).replace("Z", "+00:00")).timestamp()
|
|
||||||
duration_ms = int(max(0.0, (time.time() - start_ts) * 1000.0))
|
|
||||||
except Exception:
|
|
||||||
duration_ms = timer.get("duration_ms", 0) or 0
|
|
||||||
timer.update({
|
|
||||||
"status": "completed",
|
|
||||||
"started_at": started_at,
|
|
||||||
"finished_at": datetime.now().isoformat(),
|
|
||||||
"duration_ms": duration_ms,
|
|
||||||
})
|
|
||||||
cm.auto_save_conversation(force=True)
|
|
||||||
debug_log(
|
|
||||||
f"[ChatFlow] 取消时已将 work_timer 标记为完成: "
|
|
||||||
f"conversation_id={getattr(cm, 'current_conversation_id', None)}, started_at={started_at}"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log(f"[ChatFlow] 取消时标记 work_timer 完成失败: {exc}")
|
|
||||||
# task_stopped 事件统一在 _run_chat_task finally 中发送,避免重复
|
|
||||||
reset_system_state(terminal)
|
reset_system_state(terminal)
|
||||||
|
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|||||||
@ -22,8 +22,6 @@ from server.utils_common import debug_log, log_conn_diag
|
|||||||
from utils.host_workspace_debug import write_host_workspace_debug
|
from utils.host_workspace_debug import write_host_workspace_debug
|
||||||
from config import DATA_DIR, WORKSPACE_SKILLS_DIRNAME
|
from config import DATA_DIR, WORKSPACE_SKILLS_DIRNAME
|
||||||
from modules.goal_state_manager import GoalStateManager, REASON_USER_CANCEL
|
from modules.goal_state_manager import GoalStateManager, REASON_USER_CANCEL
|
||||||
from modules.sub_agent.state import TERMINAL_STATUSES as SUB_AGENT_TERMINAL_STATUSES
|
|
||||||
from modules.background_command_manager import BackgroundCommandManager, TERMINAL_STATUSES as BG_COMMAND_TERMINAL_STATUSES
|
|
||||||
|
|
||||||
|
|
||||||
SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n?", re.S)
|
SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n?", re.S)
|
||||||
@ -53,7 +51,6 @@ class TaskRecord:
|
|||||||
"next_event_idx",
|
"next_event_idx",
|
||||||
"runtime_pending_queue",
|
"runtime_pending_queue",
|
||||||
"runtime_guidance_queue",
|
"runtime_guidance_queue",
|
||||||
"last_cancel_at",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -90,7 +87,6 @@ class TaskRecord:
|
|||||||
self.next_event_idx: int = 0
|
self.next_event_idx: int = 0
|
||||||
self.runtime_pending_queue: List[Dict[str, Any]] = []
|
self.runtime_pending_queue: List[Dict[str, Any]] = []
|
||||||
self.runtime_guidance_queue: List[str] = []
|
self.runtime_guidance_queue: List[str] = []
|
||||||
self.last_cancel_at: Optional[float] = None
|
|
||||||
|
|
||||||
class TaskManager:
|
class TaskManager:
|
||||||
"""线程内存版任务管理器,后续可替换为 Redis/DB。"""
|
"""线程内存版任务管理器,后续可替换为 Redis/DB。"""
|
||||||
@ -206,125 +202,26 @@ class TaskManager:
|
|||||||
def cancel_task(self, username: str, task_id: str) -> bool:
|
def cancel_task(self, username: str, task_id: str) -> bool:
|
||||||
rec = self.get_task(username, task_id)
|
rec = self.get_task(username, task_id)
|
||||||
if not rec:
|
if not rec:
|
||||||
debug_log(f"[TaskCancel] cancel_task 找不到任务: task_id={task_id}")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 立即快照进入时的状态,后续所有两段式判断都用它。
|
|
||||||
# _run_chat_task finally 会在硬取消后并发修改 rec.status,
|
|
||||||
# 如果后面再读会被误判为第二下。
|
|
||||||
status_at_entry = rec.status
|
|
||||||
|
|
||||||
debug_log(
|
|
||||||
f"[TaskCancel] 入口: task_id={task_id}, status={status_at_entry}, "
|
|
||||||
f"conversation_id={rec.conversation_id}, workspace_id={rec.workspace_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 1. 硬取消主 asyncio task(如果引用还在)
|
|
||||||
entry = stop_flags.get(task_id)
|
|
||||||
terminal = None
|
|
||||||
if isinstance(entry, dict):
|
|
||||||
terminal = entry.get('terminal')
|
|
||||||
loop = entry.get('loop')
|
|
||||||
task = entry.get('task')
|
|
||||||
debug_log(
|
|
||||||
f"[TaskCancel] stop_flags entry: loop_exists={loop is not None}, "
|
|
||||||
f"task_exists={task is not None}, task_done={task.done() if task else 'n/a'}"
|
|
||||||
)
|
|
||||||
if loop and task and not task.done():
|
|
||||||
try:
|
|
||||||
loop.call_soon_threadsafe(task.cancel)
|
|
||||||
debug_log(f"[TaskCancel] 已投递硬取消: task_id={task_id}")
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log(f"[TaskCancel] 硬取消失败: task_id={task_id}, error={exc}")
|
|
||||||
entry['stop'] = True
|
|
||||||
else:
|
|
||||||
debug_log(f"[TaskCancel] stop_flags 无 entry,设软标志: task_id={task_id}")
|
|
||||||
stop_flags[task_id] = {'stop': True, 'task': None, 'terminal': None, 'loop': None}
|
|
||||||
entry = stop_flags[task_id]
|
|
||||||
|
|
||||||
rec.stop_requested = True
|
rec.stop_requested = True
|
||||||
|
# 标记停止标志;chat_flow 会检测 stop_flags
|
||||||
# 2. 停止目标模式
|
entry = stop_flags.get(task_id)
|
||||||
workspace = None
|
if not isinstance(entry, dict):
|
||||||
if rec.workspace_id:
|
entry = {'stop': False, 'task': None, 'terminal': None}
|
||||||
try:
|
stop_flags[task_id] = entry
|
||||||
_, workspace = get_user_resources(username, rec.workspace_id)
|
entry['stop'] = True
|
||||||
if workspace:
|
# 注释掉直接取消任务,改为通过停止标志让任务内部处理
|
||||||
gsm = GoalStateManager(workspace.data_dir)
|
# try:
|
||||||
if gsm.is_active():
|
# if entry.get('task') and hasattr(entry['task'], "cancel"):
|
||||||
gsm.mark_stopped(REASON_USER_CANCEL)
|
# entry['task'].cancel()
|
||||||
debug_log(f"[Goal] 用户取消任务 {task_id},同步停止工作区目标模式")
|
# except Exception:
|
||||||
except Exception as exc:
|
# pass
|
||||||
debug_log(f"[Goal] 取消任务时停止目标模式失败: {exc}")
|
|
||||||
|
|
||||||
# 3. 丢弃已经引导的内容(预输入队列保持不变,正常结束后再插入)
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
rec.runtime_guidance_queue = []
|
rec.status = "cancel_requested"
|
||||||
rec.updated_at = time.time()
|
rec.updated_at = time.time()
|
||||||
|
# 清空事件队列,防止新任务读取到旧事件
|
||||||
# 4. 两段式停止:严格按进入时的 rec.status 判断(快照),不依赖 task.done() 的异步时机。
|
|
||||||
# 原因:投递硬取消后 _run_chat_task finally 可能并发把 status 改成 cancel_requested,
|
|
||||||
# 若此时再读 rec.status 会误判为第二下,导致第一下就误杀后台。
|
|
||||||
# - 第一下(running/pending):只硬取消主智能体并置为 cancel_requested,保留后台任务。
|
|
||||||
# - 第二下(cancel_requested/stopped/canceled):清理后台任务并置为 stopped。
|
|
||||||
# - 额外保护:极短时间内(<500ms)的重复 cancel 请求视为抖动,不执行第二下。
|
|
||||||
now = time.time()
|
|
||||||
is_first_press = status_at_entry in {"running", "pending"}
|
|
||||||
|
|
||||||
if not terminal and rec.workspace_id:
|
|
||||||
try:
|
|
||||||
terminal, _ = get_user_resources(username, rec.workspace_id)
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log(f"[TaskCancel] 取消任务时重新获取 terminal 失败: {exc}")
|
|
||||||
|
|
||||||
if is_first_press:
|
|
||||||
# 第一下:主智能体仍在跑,只请求取消,保留后台任务。
|
|
||||||
# 注意 1:不要清空 rec.events,否则 _run_chat_task finally 发送的
|
|
||||||
# task_stopped 事件会被丢弃,前端无法感知并弹提示。
|
|
||||||
# 注意 2:_run_chat_task finally 可能并发先执行并把 status 设为 stopped,
|
|
||||||
# 此时不要再覆盖回 cancel_requested。
|
|
||||||
with self._lock:
|
|
||||||
if rec.status in {"running", "pending"}:
|
|
||||||
rec.status = "cancel_requested"
|
|
||||||
rec.updated_at = now
|
|
||||||
rec.last_cancel_at = now
|
|
||||||
debug_log(
|
|
||||||
f"[TaskCancel] 第一下:请求取消智能体,保留后台任务: "
|
|
||||||
f"task_id={task_id}, status_at_entry={status_at_entry}, "
|
|
||||||
f"current_status={rec.status}"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 第二下:清理后台任务(过滤 500ms 内的重复请求)
|
|
||||||
if rec.last_cancel_at is not None and (now - rec.last_cancel_at) < 0.5:
|
|
||||||
debug_log(
|
|
||||||
f"[TaskCancel] 忽略 500ms 内重复取消请求: task_id={task_id}, "
|
|
||||||
f"elapsed={now - rec.last_cancel_at:.3f}s"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
has_running_background = self._cleanup_background_tasks(rec, terminal)
|
|
||||||
with self._lock:
|
|
||||||
rec.status = "stopped"
|
|
||||||
rec.updated_at = time.time()
|
|
||||||
rec.last_cancel_at = time.time()
|
|
||||||
rec.events.clear()
|
rec.events.clear()
|
||||||
debug_log(
|
debug_log(f"[Task] 任务 {task_id} 已取消,事件队列已清空")
|
||||||
f"[TaskCancel] 第二下:清理后台并停止: task_id={task_id}, "
|
|
||||||
f"has_running_background={has_running_background}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
from server.extensions import socketio
|
|
||||||
socketio.emit('task_stopped', {
|
|
||||||
'message': '任务已停止',
|
|
||||||
'reason': 'user_requested',
|
|
||||||
'task_id': task_id,
|
|
||||||
'conversation_id': rec.conversation_id,
|
|
||||||
'has_running_sub_agents': False,
|
|
||||||
'has_running_background_commands': False,
|
|
||||||
}, room=f"user_{username}")
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log(f"[TaskCancel] 发送 task_stopped 事件失败: {exc}")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -603,89 +500,6 @@ class TaskManager:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
# ---- internal helpers ----
|
# ---- internal helpers ----
|
||||||
def _cleanup_background_tasks(self, rec: TaskRecord, terminal: Optional[Any]) -> bool:
|
|
||||||
"""清理指定任务/对话下的所有后台子智能体和后台命令,返回是否清理到任何任务。"""
|
|
||||||
has_running_background = False
|
|
||||||
if not terminal or not rec.conversation_id:
|
|
||||||
return has_running_background
|
|
||||||
|
|
||||||
sub_agent_manager = getattr(terminal, 'sub_agent_manager', None)
|
|
||||||
if sub_agent_manager:
|
|
||||||
try:
|
|
||||||
sub_agent_manager.reconcile_task_states(conversation_id=rec.conversation_id)
|
|
||||||
for task_info in list(sub_agent_manager.tasks.values()):
|
|
||||||
if task_info.get('conversation_id') != rec.conversation_id:
|
|
||||||
continue
|
|
||||||
status = task_info.get('status')
|
|
||||||
if status not in SUB_AGENT_TERMINAL_STATUSES.union({"terminated"}):
|
|
||||||
has_running_background = True
|
|
||||||
try:
|
|
||||||
sub_agent_manager.terminate_sub_agent(task_id=task_info.get('task_id'))
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log(f"[TaskCancel] 终止子智能体失败: {exc}")
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log(f"[TaskCancel] 检查后台子智能体失败: {exc}")
|
|
||||||
|
|
||||||
bg_manager = getattr(terminal, 'background_command_manager', None)
|
|
||||||
if bg_manager:
|
|
||||||
try:
|
|
||||||
bg_manager.reconcile_stale_records(conversation_id=rec.conversation_id)
|
|
||||||
waiting_items = bg_manager.list_waiting_items(rec.conversation_id)
|
|
||||||
for item in waiting_items:
|
|
||||||
has_running_background = True
|
|
||||||
try:
|
|
||||||
bg_manager.cancel_command(item.get('command_id'))
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log(f"[TaskCancel] 取消后台命令失败: {exc}")
|
|
||||||
# 把该对话下所有未通知的终态记录也标为已通知,避免后续幽灵通知
|
|
||||||
try:
|
|
||||||
with bg_manager._lock:
|
|
||||||
for record in bg_manager._records.values():
|
|
||||||
if record.get('conversation_id') != rec.conversation_id:
|
|
||||||
continue
|
|
||||||
if record.get('status') in BG_COMMAND_TERMINAL_STATUSES and not record.get('notified'):
|
|
||||||
record['notified'] = True
|
|
||||||
record['updated_at'] = time.time()
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log(f"[TaskCancel] 标记后台通知状态失败: {exc}")
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log(f"[TaskCancel] 检查后台命令失败: {exc}")
|
|
||||||
|
|
||||||
return has_running_background
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _has_running_background(rec: TaskRecord, terminal: Optional[Any]) -> Dict[str, bool]:
|
|
||||||
"""检测指定任务/对话是否还有运行中的后台子智能体或后台命令。"""
|
|
||||||
result = {"has_running_sub_agents": False, "has_running_background_commands": False}
|
|
||||||
if not terminal or not rec.conversation_id:
|
|
||||||
return result
|
|
||||||
|
|
||||||
sub_agent_manager = getattr(terminal, 'sub_agent_manager', None)
|
|
||||||
if sub_agent_manager:
|
|
||||||
try:
|
|
||||||
sub_agent_manager.reconcile_task_states(conversation_id=rec.conversation_id)
|
|
||||||
for task_info in sub_agent_manager.tasks.values():
|
|
||||||
if task_info.get('conversation_id') != rec.conversation_id:
|
|
||||||
continue
|
|
||||||
status = task_info.get('status')
|
|
||||||
if status not in SUB_AGENT_TERMINAL_STATUSES.union({"terminated"}):
|
|
||||||
result["has_running_sub_agents"] = True
|
|
||||||
break
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log(f"[Task] 检查后台子智能体失败: {exc}")
|
|
||||||
|
|
||||||
bg_manager = getattr(terminal, 'background_command_manager', None)
|
|
||||||
if bg_manager:
|
|
||||||
try:
|
|
||||||
bg_manager.reconcile_stale_records(conversation_id=rec.conversation_id)
|
|
||||||
waiting_items = bg_manager.list_waiting_items(rec.conversation_id)
|
|
||||||
if waiting_items:
|
|
||||||
result["has_running_background_commands"] = True
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log(f"[Task] 检查后台命令失败: {exc}")
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _append_event(self, rec: TaskRecord, event_type: str, data: Dict[str, Any]):
|
def _append_event(self, rec: TaskRecord, event_type: str, data: Dict[str, Any]):
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
data = dict(data)
|
data = dict(data)
|
||||||
@ -921,66 +735,9 @@ class TaskManager:
|
|||||||
|
|
||||||
# 结束状态
|
# 结束状态
|
||||||
canceled_flag = rec.stop_requested or stop_hint or bool(stop_flags.get(rec.task_id, {}).get("stop"))
|
canceled_flag = rec.stop_requested or stop_hint or bool(stop_flags.get(rec.task_id, {}).get("stop"))
|
||||||
if canceled_flag:
|
with self._lock:
|
||||||
# 用户取消时,根据是否还有后台任务决定状态:
|
rec.status = "canceled" if canceled_flag else "succeeded"
|
||||||
# - 有后台任务:保持 cancel_requested,等待用户第二下清理
|
rec.updated_at = time.time()
|
||||||
# - 无后台任务:直接 stopped
|
|
||||||
# 注意:如果 cancel_task 第二下已经先把 rec.status 设为 stopped,则保持 stopped。
|
|
||||||
bg_state = self._has_running_background(rec, terminal)
|
|
||||||
has_bg = bg_state["has_running_sub_agents"] or bg_state["has_running_background_commands"]
|
|
||||||
with self._lock:
|
|
||||||
if rec.status == "stopped":
|
|
||||||
new_status = "stopped"
|
|
||||||
else:
|
|
||||||
new_status = "cancel_requested" if has_bg else "stopped"
|
|
||||||
rec.status = new_status
|
|
||||||
rec.updated_at = time.time()
|
|
||||||
debug_log(
|
|
||||||
f"[TaskRun] 任务线程结束: task_id={rec.task_id}, canceled_flag={canceled_flag}, "
|
|
||||||
f"new_status={new_status}, bg_state={bg_state}"
|
|
||||||
)
|
|
||||||
# 统一发送 task_stopped,携带后台任务状态
|
|
||||||
try:
|
|
||||||
from server.extensions import socketio
|
|
||||||
stopped_payload = {
|
|
||||||
'message': '任务已停止',
|
|
||||||
'reason': 'user_requested',
|
|
||||||
'task_id': rec.task_id,
|
|
||||||
'conversation_id': rec.conversation_id,
|
|
||||||
'has_running_sub_agents': bg_state["has_running_sub_agents"],
|
|
||||||
'has_running_background_commands': bg_state["has_running_background_commands"],
|
|
||||||
}
|
|
||||||
socketio.emit('task_stopped', stopped_payload, room=f"user_{rec.username}")
|
|
||||||
# 同时写入轮询事件队列,确保 websocket 丢失时前端仍能收到
|
|
||||||
self._append_event(rec, "task_stopped", stopped_payload)
|
|
||||||
debug_log(
|
|
||||||
f"[TaskRun] 已发送 task_stopped: task_id={rec.task_id}, "
|
|
||||||
f"has_bg={has_bg}, room=user_{rec.username}"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log(f"[TaskRun] 发送 task_stopped 失败: {exc}")
|
|
||||||
# 持久化:将最后一条用户消息的 work_timer 标记为完成,避免刷新后仍显示工作中
|
|
||||||
try:
|
|
||||||
if terminal and getattr(terminal, "context_manager", None) and rec.conversation_id:
|
|
||||||
cm = getattr(terminal.context_manager, "conversation_manager", None)
|
|
||||||
if cm:
|
|
||||||
cm.mark_latest_user_work_completed(rec.conversation_id)
|
|
||||||
debug_log(f"[TaskRun] 已持久化 work_timer 完成: task_id={rec.task_id}, conversation_id={rec.conversation_id}")
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log(f"[TaskRun] 持久化 work_timer 完成失败: {exc}")
|
|
||||||
else:
|
|
||||||
with self._lock:
|
|
||||||
rec.status = "succeeded"
|
|
||||||
rec.updated_at = time.time()
|
|
||||||
# 正常结束时同样持久化 work_timer,保证刷新后状态一致
|
|
||||||
try:
|
|
||||||
if terminal and getattr(terminal, "context_manager", None) and rec.conversation_id:
|
|
||||||
cm = getattr(terminal.context_manager, "conversation_manager", None)
|
|
||||||
if cm:
|
|
||||||
cm.mark_latest_user_work_completed(rec.conversation_id)
|
|
||||||
debug_log(f"[TaskRun] 正常结束已持久化 work_timer 完成: task_id={rec.task_id}, conversation_id={rec.conversation_id}")
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log(f"[TaskRun] 正常结束持久化 work_timer 完成失败: {exc}")
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
debug_log(f"[Task] 后台任务失败: {exc}")
|
debug_log(f"[Task] 后台任务失败: {exc}")
|
||||||
self._append_event(rec, "error", {"message": str(exc)})
|
self._append_event(rec, "error", {"message": str(exc)})
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4m-8 10v-6m-3 3l3 3l3-3"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 328 B |
@ -851,11 +851,8 @@ const VirtualMonitorSurface = defineAsyncComponent(
|
|||||||
const PathAuthorizationDialog = defineAsyncComponent(
|
const PathAuthorizationDialog = defineAsyncComponent(
|
||||||
() => import('./components/overlay/PathAuthorizationDialog.vue')
|
() => import('./components/overlay/PathAuthorizationDialog.vue')
|
||||||
);
|
);
|
||||||
|
|
||||||
const tutorialStore = useTutorialStore();
|
const tutorialStore = useTutorialStore();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const mobilePanelIcon = new URL('../icons/align-left.svg', import.meta.url).href;
|
const mobilePanelIcon = new URL('../icons/align-left.svg', import.meta.url).href;
|
||||||
const detectAppShell = () => {
|
const detectAppShell = () => {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
@ -873,7 +870,6 @@ onMounted(() => {
|
|||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
isAppShell.value = detectAppShell();
|
isAppShell.value = detectAppShell();
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
});
|
});
|
||||||
const mobileMenuIcons = {
|
const mobileMenuIcons = {
|
||||||
workspace: new URL('../icons/folder.svg', import.meta.url).href,
|
workspace: new URL('../icons/folder.svg', import.meta.url).href,
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import katex from 'katex';
|
import katex from 'katex';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { createApp } from 'vue';
|
|
||||||
import ShowFileCard from '../components/chat/ShowFileCard.vue';
|
|
||||||
|
|
||||||
let showImageRenderSeq = 0;
|
let showImageRenderSeq = 0;
|
||||||
let showImageDebugLogCount = 0;
|
let showImageDebugLogCount = 0;
|
||||||
@ -432,17 +430,10 @@ function renderShowImages(root: ParentNode | null = document) {
|
|||||||
const htmlNodes = Array.from(
|
const htmlNodes = Array.from(
|
||||||
root.querySelectorAll('show_html:not([data-rendered]), show-html:not([data-rendered])')
|
root.querySelectorAll('show_html:not([data-rendered]), show-html:not([data-rendered])')
|
||||||
);
|
);
|
||||||
const fileNodes = Array.from(
|
if (htmlNodes.length > 0) {
|
||||||
root.querySelectorAll('show_file:not([data-rendered]), show-file:not([data-rendered])')
|
|
||||||
);
|
|
||||||
// download:// 链接也需要绑定,即使没有 show 标签
|
|
||||||
const hasDownloadLinks = !!root.querySelector(
|
|
||||||
'a.md-download-link:not([data-download-bound]), a[data-download="1"]:not([data-download-bound])'
|
|
||||||
);
|
|
||||||
if (htmlNodes.length > 0 || fileNodes.length > 0) {
|
|
||||||
markShowTagDrawingActive();
|
markShowTagDrawingActive();
|
||||||
}
|
}
|
||||||
if (!verbose && imageNodes.length === 0 && htmlNodes.length === 0 && fileNodes.length === 0 && !hasDownloadLinks) return;
|
if (!verbose && imageNodes.length === 0 && htmlNodes.length === 0) return;
|
||||||
|
|
||||||
const rootNode = root as Node;
|
const rootNode = root as Node;
|
||||||
debugShowImageLog('render:start', {
|
debugShowImageLog('render:start', {
|
||||||
@ -453,7 +444,6 @@ function renderShowImages(root: ParentNode | null = document) {
|
|||||||
: `nodeType:${rootNode?.nodeType}`,
|
: `nodeType:${rootNode?.nodeType}`,
|
||||||
pendingImageCount: imageNodes.length,
|
pendingImageCount: imageNodes.length,
|
||||||
pendingHtmlCount: htmlNodes.length,
|
pendingHtmlCount: htmlNodes.length,
|
||||||
pendingFileCount: fileNodes.length,
|
|
||||||
orderBefore: collectShowImageOrder(root)
|
orderBefore: collectShowImageOrder(root)
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -941,16 +931,6 @@ function renderShowImages(root: ParentNode | null = document) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== show_file 预览卡片 =====
|
|
||||||
fileNodes.forEach((node) => {
|
|
||||||
if (!node.isConnected || node.hasAttribute('data-rendered')) return;
|
|
||||||
renderShowFileCard(node);
|
|
||||||
node.setAttribute('data-rendered', '1');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== download:// 链接事件绑定 =====
|
|
||||||
bindDownloadLinks(root);
|
|
||||||
|
|
||||||
debugShowImageLog('render:end', {
|
debugShowImageLog('render:end', {
|
||||||
renderId,
|
renderId,
|
||||||
orderAfter: collectShowImageOrder(root)
|
orderAfter: collectShowImageOrder(root)
|
||||||
@ -1242,15 +1222,15 @@ export function setupShowImageObserver() {
|
|||||||
const hasShowImageRelatedMutation = mutations.some((mutation) => {
|
const hasShowImageRelatedMutation = mutations.some((mutation) => {
|
||||||
const targetIsShowImage =
|
const targetIsShowImage =
|
||||||
mutation.target instanceof Element &&
|
mutation.target instanceof Element &&
|
||||||
['show_image', 'show_html', 'show_file', 'show-image', 'show-html', 'show-file'].includes(
|
['show_image', 'show_html', 'show-image', 'show-html'].includes(
|
||||||
mutation.target.tagName.toLowerCase()
|
mutation.target.tagName.toLowerCase()
|
||||||
);
|
);
|
||||||
const addedHasShowTag = Array.from(mutation.addedNodes).some(
|
const addedHasShowTag = Array.from(mutation.addedNodes).some(
|
||||||
(n) =>
|
(n) =>
|
||||||
n instanceof Element &&
|
n instanceof Element &&
|
||||||
(['show_image', 'show_html', 'show_file', 'show-image', 'show-html', 'show-file'].includes(
|
(['show_image', 'show_html', 'show-image', 'show-html'].includes(
|
||||||
n.tagName.toLowerCase()
|
n.tagName.toLowerCase()
|
||||||
) || !!n.querySelector?.('show_image,show_html,show_file,show-image,show-html,show-file,a.md-download-link,a[data-download="1"]'))
|
) || !!n.querySelector?.('show_image,show_html,show-image,show-html'))
|
||||||
);
|
);
|
||||||
return targetIsShowImage || addedHasShowTag;
|
return targetIsShowImage || addedHasShowTag;
|
||||||
});
|
});
|
||||||
@ -1345,94 +1325,6 @@ function updateViewportHeightVar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== show_file 预览卡片渲染 =====
|
|
||||||
|
|
||||||
function dispatchShowFileDownload(path: string) {
|
|
||||||
// 直接 fetch 下载接口,不走 Vue 组件链
|
|
||||||
const url = `/api/download/file?path=${encodeURIComponent(path)}`;
|
|
||||||
const name = path.split('/').pop() || 'file';
|
|
||||||
fetch(url)
|
|
||||||
.then((resp) => {
|
|
||||||
if (!resp.ok) {
|
|
||||||
return resp.json().then((j) => {
|
|
||||||
throw new Error(j.error || j.message || resp.statusText);
|
|
||||||
}).catch(() => {
|
|
||||||
throw new Error(resp.statusText || '下载失败');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return resp.blob();
|
|
||||||
})
|
|
||||||
.then((blob) => {
|
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = blobUrl;
|
|
||||||
a.download = name;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.warn('[show_file] 下载失败:', err);
|
|
||||||
// 兜底:直接跳转
|
|
||||||
window.open(url, '_blank');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跟踪已挂载的 show_file Vue 应用,便于卸载
|
|
||||||
const showFileAppMap = new WeakMap<Element, any>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将 <show_file> 节点渲染为 Vue 预览卡片组件。
|
|
||||||
* 只读取 path 属性,预览内容直接在卡片内部展开。
|
|
||||||
*/
|
|
||||||
function renderShowFileCard(node: Element) {
|
|
||||||
const path = (node.getAttribute('path') || '').trim().replace(/^\/+/, '');
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
node.replaceChildren(document.createTextNode('[show_file: 缺少 path 属性]'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理旧实例
|
|
||||||
const oldApp = showFileAppMap.get(node);
|
|
||||||
if (oldApp) {
|
|
||||||
try { oldApp.unmount(); } catch {}
|
|
||||||
showFileAppMap.delete(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mountEl = document.createElement('div');
|
|
||||||
mountEl.className = 'show-file-card-root';
|
|
||||||
node.replaceChildren(mountEl);
|
|
||||||
|
|
||||||
const app = createApp(ShowFileCard, { path });
|
|
||||||
app.mount(mountEl);
|
|
||||||
showFileAppMap.set(node, app);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 绑定 download:// 链接的点击事件。
|
|
||||||
* markdown 里的 [文件](download:///path) 会被渲染为 <a class="md-download-link" href="download://..." data-path="/path">。
|
|
||||||
*/
|
|
||||||
function bindDownloadLinks(root: ParentNode) {
|
|
||||||
// 优先用 data-download 属性选择(不被 sanitize 影响),
|
|
||||||
// md-download-link class 作为兼容回退
|
|
||||||
const links = root.querySelectorAll<HTMLAnchorElement>(
|
|
||||||
'a[data-download="1"]:not([data-download-bound]), a.md-download-link:not([data-download-bound])'
|
|
||||||
);
|
|
||||||
links.forEach((link) => {
|
|
||||||
link.setAttribute('data-download-bound', '1');
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const path = link.getAttribute('data-path') || '';
|
|
||||||
if (path) {
|
|
||||||
dispatchShowFileDownload(path);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.katex = katex;
|
window.katex = katex;
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { debugLog, goalModeDebugLog } from '../common';
|
import { debugLog } from '../common';
|
||||||
import { useTaskStore } from '../../../stores/task';
|
import { useTaskStore } from '../../../stores/task';
|
||||||
import { useModelStore } from '../../../stores/model';
|
import { useModelStore } from '../../../stores/model';
|
||||||
import {
|
import {
|
||||||
@ -335,13 +335,7 @@ export const sendMethods = {
|
|||||||
},
|
},
|
||||||
async stopTask() {
|
async stopTask() {
|
||||||
console.log('[DEBUG_AWAITING] ===== stopTask =====');
|
console.log('[DEBUG_AWAITING] ===== stopTask =====');
|
||||||
if (this._stopTaskRunning) {
|
|
||||||
goalModeDebugLog('stopTask:debounce-rejected', { stopRequested: this.stopRequested });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._stopTaskRunning = true;
|
|
||||||
if (this.compressionInProgress) {
|
if (this.compressionInProgress) {
|
||||||
this._stopTaskRunning = false;
|
|
||||||
this.uiPushToast({
|
this.uiPushToast({
|
||||||
title: '对话自动压缩中',
|
title: '对话自动压缩中',
|
||||||
message: '压缩进行中,当前不可停止任务',
|
message: '压缩进行中,当前不可停止任务',
|
||||||
@ -351,16 +345,7 @@ export const sendMethods = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canStop = this.composerBusy && !this.stopRequested;
|
const canStop = this.composerBusy && !this.stopRequested;
|
||||||
goalModeDebugLog('stopTask:entry', {
|
|
||||||
composerBusy: this.composerBusy,
|
|
||||||
stopRequested: this.stopRequested,
|
|
||||||
taskInProgress: this.taskInProgress,
|
|
||||||
streamingUi: this.streamingUi,
|
|
||||||
canStop,
|
|
||||||
currentTaskId: this.currentTaskId,
|
|
||||||
});
|
|
||||||
if (!canStop) {
|
if (!canStop) {
|
||||||
this._stopTaskRunning = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -383,19 +368,15 @@ export const sendMethods = {
|
|||||||
|
|
||||||
if (taskStore.currentTaskId) {
|
if (taskStore.currentTaskId) {
|
||||||
await taskStore.cancelTask();
|
await taskStore.cancelTask();
|
||||||
|
// 立即停止轮询,防止旧任务的目标模式事件在切换对话后仍被处理
|
||||||
|
taskStore.stopPolling('user_stop');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等待后端确认;轮询继续,task_stopped 事件到达后会由 taskStore 自动停止轮询
|
// 等待后端确认
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
const shouldKeepBusy =
|
const shouldKeepBusy =
|
||||||
['running', 'pending', 'cancel_requested', 'canceled'].includes(String(taskStore.taskStatus));
|
Boolean(taskStore.currentTaskId) &&
|
||||||
|
['running', 'pending', 'cancel_requested'].includes(String(taskStore.taskStatus));
|
||||||
goalModeDebugLog('stopTask:try-end', {
|
|
||||||
currentTaskId: taskStore.currentTaskId,
|
|
||||||
taskStatus: taskStore.taskStatus,
|
|
||||||
shouldKeepBusy,
|
|
||||||
streamingMessage: this.streamingMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 清理前端状态
|
// 清理前端状态
|
||||||
this.clearPendingTools('user_stop');
|
this.clearPendingTools('user_stop');
|
||||||
@ -433,14 +414,8 @@ export const sendMethods = {
|
|||||||
const { useTaskStore } = await import('../../../stores/task');
|
const { useTaskStore } = await import('../../../stores/task');
|
||||||
const taskStore = useTaskStore();
|
const taskStore = useTaskStore();
|
||||||
const shouldKeepBusy =
|
const shouldKeepBusy =
|
||||||
['running', 'pending', 'cancel_requested', 'canceled'].includes(String(taskStore.taskStatus));
|
Boolean(taskStore.currentTaskId) &&
|
||||||
|
['running', 'pending', 'cancel_requested'].includes(String(taskStore.taskStatus));
|
||||||
goalModeDebugLog('stopTask:catch', {
|
|
||||||
error: String(error),
|
|
||||||
currentTaskId: taskStore.currentTaskId,
|
|
||||||
taskStatus: taskStore.taskStatus,
|
|
||||||
shouldKeepBusy,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 即使失败也清理状态
|
// 即使失败也清理状态
|
||||||
this.clearPendingTools('user_stop');
|
this.clearPendingTools('user_stop');
|
||||||
@ -460,15 +435,14 @@ export const sendMethods = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.uiPushToast({
|
this.uiPushToast({
|
||||||
title: '停止请求已发送',
|
title: '停止失败',
|
||||||
message: '若任务未停止,请再次点击停止按钮',
|
message: '任务可能仍在运行,请刷新页面',
|
||||||
type: 'info'
|
type: 'warning'
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
// 确保清除 dropToolEvents 和 stopRequested 标志
|
// 确保清除 dropToolEvents 和 stopRequested 标志
|
||||||
this.dropToolEvents = false;
|
this.dropToolEvents = false;
|
||||||
this.stopRequested = false;
|
this.stopRequested = false;
|
||||||
this._stopTaskRunning = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { debugLog, goalModeDebugLog } from '../common';
|
import { debugLog } from '../common';
|
||||||
import { useTaskStore } from '../../../stores/task';
|
import { useTaskStore } from '../../../stores/task';
|
||||||
import { getMessageVisibility, messageStartsWork } from '../../../utils/messageVisibility';
|
import { getMessageVisibility, messageStartsWork } from '../../../utils/messageVisibility';
|
||||||
import {
|
import {
|
||||||
@ -106,14 +106,10 @@ export const lifecycleMethods = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 检查事件 task_id 是否仍是当前正在接管的任务,防止切换/新建对话后旧轮询的在途响应串写。
|
// 检查事件 task_id 是否仍是当前正在接管的任务,防止切换/新建对话后旧轮询的在途响应串写。
|
||||||
// task_stopped / task_complete / error 属于任务终态事件,即使 currentTaskId 已被清理
|
|
||||||
//(例如用户点击停止后 stopPolling 清空了 ID),只要 conversation_id 匹配就应该处理。
|
|
||||||
const isTerminalTaskEvent = ['task_complete', 'task_stopped', 'error'].includes(eventType);
|
|
||||||
if (
|
if (
|
||||||
!crossConversationAllowed.has(eventType) &&
|
!crossConversationAllowed.has(eventType) &&
|
||||||
eventData.task_id &&
|
eventData.task_id &&
|
||||||
(!taskStore.currentTaskId || eventData.task_id !== taskStore.currentTaskId) &&
|
(!taskStore.currentTaskId || eventData.task_id !== taskStore.currentTaskId)
|
||||||
!(isTerminalTaskEvent && eventData.conversation_id === this.currentConversationId)
|
|
||||||
) {
|
) {
|
||||||
restoreDebugLog('event:drop-task-mismatch', {
|
restoreDebugLog('event:drop-task-mismatch', {
|
||||||
eventType,
|
eventType,
|
||||||
@ -445,7 +441,6 @@ export const lifecycleMethods = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
handleTaskStopped(data: any, eventIdx: number) {
|
handleTaskStopped(data: any, eventIdx: number) {
|
||||||
goalModeDebugLog('handleTaskStopped:entered', { eventIdx, data });
|
|
||||||
jsonDebug('handleTaskStopped:before', {
|
jsonDebug('handleTaskStopped:before', {
|
||||||
eventIdx,
|
eventIdx,
|
||||||
data,
|
data,
|
||||||
@ -455,67 +450,28 @@ export const lifecycleMethods = {
|
|||||||
});
|
});
|
||||||
debugLog('[TaskPolling] 任务已停止, idx:', eventIdx, data);
|
debugLog('[TaskPolling] 任务已停止, idx:', eventIdx, data);
|
||||||
|
|
||||||
const hasRunningSubAgents = !!data?.has_running_sub_agents;
|
this.markLatestUserWorkCompleted();
|
||||||
const hasRunningBackgroundCommands = !!data?.has_running_background_commands;
|
|
||||||
const hasRunningBackground = hasRunningSubAgents || hasRunningBackgroundCommands;
|
|
||||||
|
|
||||||
goalModeDebugLog('handleTaskStopped', {
|
|
||||||
taskInProgress: this.taskInProgress,
|
|
||||||
streamingMessage: this.streamingMessage,
|
|
||||||
hasRunningSubAgents,
|
|
||||||
hasRunningBackgroundCommands,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.cleanupTrailingEmptyAssistantPlaceholder('task_stopped');
|
this.cleanupTrailingEmptyAssistantPlaceholder('task_stopped');
|
||||||
this.streamingMessage = false;
|
this.streamingMessage = false;
|
||||||
|
this.taskInProgress = false;
|
||||||
this.stopRequested = false;
|
this.stopRequested = false;
|
||||||
|
this.waitingForSubAgent = false;
|
||||||
|
this.waitingForBackgroundCommand = false;
|
||||||
|
this.stopWaitingTaskProbe();
|
||||||
|
|
||||||
// 智能体工作本身已结束,无论是否还有后台任务,先把 work_timer 标记为完成
|
if (typeof this.clearPendingTools === 'function') {
|
||||||
this.markLatestUserWorkCompleted();
|
this.clearPendingTools('task_stopped');
|
||||||
|
|
||||||
if (hasRunningBackground) {
|
|
||||||
// 智能体已停,但后台任务还在跑:保持停止按钮,等用户第二下清理
|
|
||||||
this.taskInProgress = true;
|
|
||||||
this.waitingForSubAgent = hasRunningSubAgents;
|
|
||||||
this.waitingForBackgroundCommand = hasRunningBackgroundCommands;
|
|
||||||
debugLog('[TaskPolling] 任务已停止,仍有后台任务运行,保持停止态');
|
|
||||||
goalModeDebugLog('handleTaskStopped:show-toast', { hasRunningSubAgents, hasRunningBackgroundCommands });
|
|
||||||
try {
|
|
||||||
this.uiPushToast({
|
|
||||||
title: '智能体已停止',
|
|
||||||
message: '再次按下停止按钮以结束后台任务',
|
|
||||||
type: 'info'
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[TaskPolling] uiPushToast 调用失败:', err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.taskInProgress = false;
|
|
||||||
this.waitingForSubAgent = false;
|
|
||||||
this.waitingForBackgroundCommand = false;
|
|
||||||
this.stopWaitingTaskProbe();
|
|
||||||
if (typeof this.clearPendingTools === 'function') {
|
|
||||||
this.clearPendingTools('task_stopped');
|
|
||||||
}
|
|
||||||
this.clearTaskState();
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (typeof this.tryAutoSendRuntimeQueuedMessages === 'function') {
|
|
||||||
this.tryAutoSendRuntimeQueuedMessages('task_stopped');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
goalModeDebugLog('handleTaskStopped:after', {
|
|
||||||
taskInProgress: this.taskInProgress,
|
|
||||||
waitingForSubAgent: this.waitingForSubAgent,
|
|
||||||
waitingForBackgroundCommand: this.waitingForBackgroundCommand,
|
|
||||||
hasRunningBackground,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.scheduleTodoListRefresh(100);
|
this.scheduleTodoListRefresh(100);
|
||||||
setTimeout(() => this.refreshRunningWorkspaceTasks?.(), 0);
|
setTimeout(() => this.refreshRunningWorkspaceTasks?.(), 0);
|
||||||
this.$forceUpdate();
|
this.$forceUpdate();
|
||||||
|
|
||||||
|
this.clearTaskState();
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (typeof this.tryAutoSendRuntimeQueuedMessages === 'function') {
|
||||||
|
this.tryAutoSendRuntimeQueuedMessages('task_stopped');
|
||||||
|
}
|
||||||
|
});
|
||||||
jsonDebug('handleTaskStopped:after', {
|
jsonDebug('handleTaskStopped:after', {
|
||||||
taskInProgress: this.taskInProgress,
|
taskInProgress: this.taskInProgress,
|
||||||
streamingMessage: this.streamingMessage,
|
streamingMessage: this.streamingMessage,
|
||||||
|
|||||||
@ -1,497 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="show-file-card">
|
|
||||||
<div class="sfc-header">
|
|
||||||
<span class="sfc-file-icon" aria-hidden="true"></span>
|
|
||||||
<span class="sfc-name" :title="displayName">{{ displayName }}</span>
|
|
||||||
<div class="sfc-actions">
|
|
||||||
<button
|
|
||||||
v-if="canAndroidPdfPreview"
|
|
||||||
type="button"
|
|
||||||
class="sfc-btn sfc-btn-preview"
|
|
||||||
@click="previewPdf"
|
|
||||||
>
|
|
||||||
预览
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="canCopy"
|
|
||||||
type="button"
|
|
||||||
class="sfc-btn"
|
|
||||||
@click="copyContent"
|
|
||||||
>
|
|
||||||
{{ copied ? '已复制' : '复制' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="sfc-btn sfc-btn-download"
|
|
||||||
@click="handleDownload"
|
|
||||||
>
|
|
||||||
下载
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sfc-preview" v-if="canPreview">
|
|
||||||
<div class="sfc-loading" v-if="loading">
|
|
||||||
<span class="sfc-spinner"></span>
|
|
||||||
<span>加载中...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sfc-error" v-else-if="error">{{ error }}</div>
|
|
||||||
|
|
||||||
<template v-else-if="fileType === 'text' || fileType === 'code' || fileType === 'json'">
|
|
||||||
<pre class="sfc-code" v-html="highlightedCode"></pre>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="fileType === 'csv'">
|
|
||||||
<div class="sfc-csv-scroll">
|
|
||||||
<table class="sfc-csv-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th v-for="(col, i) in csvHeader" :key="i">{{ col }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="(row, ri) in csvRows" :key="ri">
|
|
||||||
<td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="sfc-csv-meta" v-if="csvTruncated">
|
|
||||||
仅显示前 {{ csvRows.length }} 行,完整内容请下载查看
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="fileType === 'markdown'">
|
|
||||||
<div class="sfc-md" v-html="renderedMarkdown"></div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="fileType === 'image'">
|
|
||||||
<img
|
|
||||||
class="sfc-image"
|
|
||||||
:src="contentUrl"
|
|
||||||
:alt="displayName"
|
|
||||||
@click="openFullImage"
|
|
||||||
@error="onImageError"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="fileType === 'pdf'">
|
|
||||||
<iframe
|
|
||||||
class="sfc-pdf"
|
|
||||||
:src="contentUrl"
|
|
||||||
:title="displayName"
|
|
||||||
></iframe>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
|
||||||
import Prism from 'prismjs';
|
|
||||||
import { renderMarkdown } from '../../composables/useMarkdownRenderer';
|
|
||||||
|
|
||||||
defineOptions({ name: 'ShowFileCard' });
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
path: string;
|
|
||||||
name?: string;
|
|
||||||
type?: string;
|
|
||||||
description?: string;
|
|
||||||
preview?: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// ---- 环境检测 ----
|
|
||||||
const isAndroidApp = typeof window !== 'undefined' && Boolean((window as any).AndroidPdfBridge);
|
|
||||||
|
|
||||||
// ---- 状态 ----
|
|
||||||
const loading = ref(false);
|
|
||||||
const error = ref('');
|
|
||||||
const rawContent = ref('');
|
|
||||||
const copied = ref(false);
|
|
||||||
let objectUrl: string | null = null;
|
|
||||||
const contentUrl = ref('');
|
|
||||||
|
|
||||||
// ---- 计算属性 ----
|
|
||||||
const fileType = computed(() => props.type || inferShowFileType(props.path));
|
|
||||||
const displayName = computed(() => props.name || props.path.split('/').pop() || 'file');
|
|
||||||
|
|
||||||
const canPreview = computed(() => {
|
|
||||||
if (props.preview === 'off') return false;
|
|
||||||
// Android App 内 PDF 不走 iframe 内嵌预览,由原生 PdfPreviewActivity 处理
|
|
||||||
if (isAndroidApp && fileType.value === 'pdf') return false;
|
|
||||||
return ['text', 'code', 'json', 'csv', 'markdown', 'image', 'pdf'].includes(fileType.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
const canAndroidPdfPreview = computed(() => {
|
|
||||||
return isAndroidApp && fileType.value === 'pdf';
|
|
||||||
});
|
|
||||||
|
|
||||||
const canCopy = computed(() => ['text', 'code', 'json', 'csv', 'markdown'].includes(fileType.value));
|
|
||||||
|
|
||||||
const metaText = computed(() => {
|
|
||||||
if (!rawContent.value) return '';
|
|
||||||
const sizeKB = (new Blob([rawContent.value]).size / 1024).toFixed(1);
|
|
||||||
return `${sizeKB} KB`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const highlightedCode = computed(() => {
|
|
||||||
if (!rawContent.value) return '';
|
|
||||||
let content = rawContent.value;
|
|
||||||
if (fileType.value === 'json') {
|
|
||||||
try { content = JSON.stringify(JSON.parse(content), null, 2); } catch {}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const grammar = Prism.languages[displayLang.value];
|
|
||||||
if (grammar) return Prism.highlight(content, grammar, displayLang.value);
|
|
||||||
} catch {}
|
|
||||||
return content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderedMarkdown = computed(() => {
|
|
||||||
if (!rawContent.value) return '';
|
|
||||||
return renderMarkdown(rawContent.value, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
const csvParsed = computed(() => {
|
|
||||||
if (fileType.value !== 'csv' || !rawContent.value) return null;
|
|
||||||
const lines = rawContent.value.split(/\r?\n/).filter(l => l.trim());
|
|
||||||
const parseLine = (line: string): string[] => {
|
|
||||||
const result: string[] = [];
|
|
||||||
let current = '';
|
|
||||||
let inQuote = false;
|
|
||||||
for (let i = 0; i < line.length; i++) {
|
|
||||||
const ch = line[i];
|
|
||||||
if (inQuote) {
|
|
||||||
if (ch === '"' && line[i + 1] === '"') { current += '"'; i++; }
|
|
||||||
else if (ch === '"') inQuote = false;
|
|
||||||
else current += ch;
|
|
||||||
} else {
|
|
||||||
if (ch === '"') inQuote = true;
|
|
||||||
else if (ch === ',') { result.push(current); current = ''; }
|
|
||||||
else current += ch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.push(current);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
const maxRows = 100;
|
|
||||||
const header = parseLine(lines[0] || '');
|
|
||||||
const rows = lines.slice(1, maxRows + 1).map(parseLine);
|
|
||||||
return { header, rows, truncated: lines.length > maxRows + 1 };
|
|
||||||
});
|
|
||||||
const csvHeader = computed(() => csvParsed.value?.header || []);
|
|
||||||
const csvRows = computed(() => csvParsed.value?.rows || []);
|
|
||||||
const csvTruncated = computed(() => csvParsed.value?.truncated || false);
|
|
||||||
|
|
||||||
// ---- 方法 ----
|
|
||||||
function inferShowFileType(path: string): string {
|
|
||||||
const extMap: Record<string, string> = {
|
|
||||||
'.txt': 'text',
|
|
||||||
'.md': 'markdown', '.markdown': 'markdown',
|
|
||||||
'.json': 'json', '.json5': 'json',
|
|
||||||
'.csv': 'csv', '.tsv': 'csv',
|
|
||||||
'.yaml': 'text', '.yml': 'text', '.toml': 'text',
|
|
||||||
'.ini': 'text', '.cfg': 'text', '.conf': 'text', '.log': 'text',
|
|
||||||
'.js': 'code', '.ts': 'code', '.jsx': 'code', '.tsx': 'code',
|
|
||||||
'.vue': 'code', '.py': 'code', '.rb': 'code', '.go': 'code',
|
|
||||||
'.rs': 'code', '.java': 'code', '.c': 'code', '.h': 'code',
|
|
||||||
'.cpp': 'code', '.hpp': 'code', '.cs': 'code', '.php': 'code',
|
|
||||||
'.swift': 'code', '.kt': 'code', '.sh': 'code', '.bash': 'code',
|
|
||||||
'.html': 'code', '.htm': 'code', '.css': 'code', '.scss': 'code',
|
|
||||||
'.less': 'code', '.xml': 'code', '.svg': 'image', '.sql': 'code',
|
|
||||||
'.png': 'image', '.jpg': 'image', '.jpeg': 'image',
|
|
||||||
'.gif': 'image', '.webp': 'image', '.bmp': 'image', '.ico': 'image',
|
|
||||||
'.avif': 'image',
|
|
||||||
'.pdf': 'pdf'
|
|
||||||
};
|
|
||||||
const lowerPath = path.toLowerCase();
|
|
||||||
const dotIdx = lowerPath.lastIndexOf('.');
|
|
||||||
if (dotIdx >= 0) {
|
|
||||||
const ext = lowerPath.slice(dotIdx);
|
|
||||||
if (extMap[ext]) return extMap[ext];
|
|
||||||
}
|
|
||||||
return 'binary';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildContentUrl() {
|
|
||||||
return `/api/file/content?path=${encodeURIComponent(props.path)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadContent() {
|
|
||||||
loading.value = true;
|
|
||||||
error.value = '';
|
|
||||||
try {
|
|
||||||
if (['image', 'pdf'].includes(fileType.value)) {
|
|
||||||
contentUrl.value = buildContentUrl();
|
|
||||||
} else {
|
|
||||||
const resp = await fetch(buildContentUrl());
|
|
||||||
if (!resp.ok) {
|
|
||||||
let msg = resp.statusText;
|
|
||||||
try { const j = await resp.json(); msg = j.error || msg; } catch {}
|
|
||||||
error.value = msg || '加载失败';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rawContent.value = await resp.text();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
error.value = (e as Error).message || '网络错误';
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDownload() {
|
|
||||||
const url = `/api/download/file?path=${encodeURIComponent(props.path)}`;
|
|
||||||
const name = props.path.split('/').pop() || 'file';
|
|
||||||
try {
|
|
||||||
const resp = await fetch(url);
|
|
||||||
if (!resp.ok) {
|
|
||||||
let msg = resp.statusText;
|
|
||||||
try { const j = await resp.json(); msg = j.error || msg; } catch {}
|
|
||||||
throw new Error(msg || '下载失败');
|
|
||||||
}
|
|
||||||
const blob = await resp.blob();
|
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = blobUrl;
|
|
||||||
a.download = name;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[ShowFileCard] 下载失败:', e);
|
|
||||||
window.open(url, '_blank');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyContent() {
|
|
||||||
if (!rawContent.value) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(rawContent.value);
|
|
||||||
copied.value = true;
|
|
||||||
setTimeout(() => { copied.value = false; }, 2000);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openFullImage() {
|
|
||||||
if (contentUrl.value) window.open(contentUrl.value, '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
function previewPdf() {
|
|
||||||
const bridge = (window as any).AndroidPdfBridge;
|
|
||||||
if (bridge && typeof bridge.previewPdf === 'function') {
|
|
||||||
bridge.previewPdf(contentUrl.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onImageError() {
|
|
||||||
error.value = '图片加载失败';
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (canPreview.value) {
|
|
||||||
loadContent();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.show-file-card {
|
|
||||||
border: 1px solid var(--border-default);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--surface-base);
|
|
||||||
overflow: hidden;
|
|
||||||
margin: 8px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-header {
|
|
||||||
min-height: 44px;
|
|
||||||
background: var(--surface-raised);
|
|
||||||
padding: 0 12px 0 18px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
border-bottom: 1px solid var(--border-default);
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-file-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
background-color: var(--text-secondary);
|
|
||||||
-webkit-mask: url('/static/icons/file-down.svg') no-repeat center / contain;
|
|
||||||
mask: url('/static/icons/file-down.svg') no-repeat center / contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-name {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-actions {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 1px solid var(--border-default);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--surface-base);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, border-color 0.15s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--surface-muted);
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-btn-download {
|
|
||||||
color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-btn-preview {
|
|
||||||
color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-preview {
|
|
||||||
border-top: 1px solid var(--border-default);
|
|
||||||
max-height: 360px;
|
|
||||||
overflow: auto;
|
|
||||||
position: relative;
|
|
||||||
background: var(--surface-base);
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--text-muted) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-preview::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-preview::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-preview::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--text-muted);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-loading,
|
|
||||||
.sfc-error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-error {
|
|
||||||
color: var(--state-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-spinner {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border: 2px solid var(--border-default);
|
|
||||||
border-top-color: var(--accent-primary);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: sfc-spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sfc-spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-code {
|
|
||||||
margin: 0;
|
|
||||||
padding: 12px;
|
|
||||||
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--surface-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-csv-scroll {
|
|
||||||
overflow: auto;
|
|
||||||
max-height: 350px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-csv-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-csv-table th,
|
|
||||||
.sfc-csv-table td {
|
|
||||||
padding: 6px 10px;
|
|
||||||
border: 1px solid var(--border-soft);
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-csv-table th {
|
|
||||||
background: var(--surface-soft);
|
|
||||||
font-weight: 600;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-csv-meta {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
text-align: center;
|
|
||||||
border-top: 1px solid var(--border-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-md {
|
|
||||||
padding: 12px;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-image {
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
cursor: zoom-in;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfc-pdf {
|
|
||||||
width: 100%;
|
|
||||||
height: 400px;
|
|
||||||
border: none;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1573,22 +1573,7 @@ export async function initializeLegacySocket(ctx: any) {
|
|||||||
// 任务停止
|
// 任务停止
|
||||||
ctx.socket.on('task_stopped', (data) => {
|
ctx.socket.on('task_stopped', (data) => {
|
||||||
socketLog('任务已停止:', data.message);
|
socketLog('任务已停止:', data.message);
|
||||||
const hasRunningSubAgents = !!data?.has_running_sub_agents;
|
ctx.taskInProgress = false;
|
||||||
const hasRunningBackgroundCommands = !!data?.has_running_background_commands;
|
|
||||||
const hasRunningBackground = hasRunningSubAgents || hasRunningBackgroundCommands;
|
|
||||||
if (!hasRunningBackground) {
|
|
||||||
ctx.taskInProgress = false;
|
|
||||||
} else {
|
|
||||||
ctx.waitingForSubAgent = hasRunningSubAgents;
|
|
||||||
ctx.waitingForBackgroundCommand = hasRunningBackgroundCommands;
|
|
||||||
if (typeof ctx.uiPushToast === 'function') {
|
|
||||||
ctx.uiPushToast({
|
|
||||||
title: '智能体已停止',
|
|
||||||
message: '再次按下停止按钮以结束后台任务',
|
|
||||||
type: 'info'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.scheduleResetAfterTask('socket:task_stopped', { preserveMonitorWindows: true });
|
ctx.scheduleResetAfterTask('socket:task_stopped', { preserveMonitorWindows: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -283,27 +283,6 @@ function transformShowImageBlocks(raw: string) {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 预处理 <show_file> 标签(自闭合或开闭形式)。
|
|
||||||
* markdown parser 在 inline 位置会把自定义标签当文本转义,
|
|
||||||
* 转成显式闭合形式后可以被 rehypeRaw 识别为 raw HTML 元素。
|
|
||||||
* show_file 不含富文本内容,不需要 base64 编码。
|
|
||||||
*/
|
|
||||||
function transformShowFileBlocks(raw: string) {
|
|
||||||
if (!raw || (raw.indexOf('show_file') === -1 && raw.indexOf('show-file') === -1)) return raw;
|
|
||||||
// 1. 自闭合:<show_file .../> 或 <show-file .../>
|
|
||||||
let output = raw.replace(
|
|
||||||
/<show[_-]file\b([^>]*?)\s*\/>/gi,
|
|
||||||
(match, attrs: string) => `<show-file${attrs}></show-file>`
|
|
||||||
);
|
|
||||||
// 2. 开闭形式:<show_file ...> ... </show_file>,忽略内部内容,只保留属性
|
|
||||||
output = output.replace(
|
|
||||||
/<show[_-]file\b([^>]*)>([\s\S]*?)<\/show[_-]file>/gi,
|
|
||||||
(match, attrs: string) => `<show-file${attrs}></show-file>`
|
|
||||||
);
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
type RehypeProps = Record<string, unknown>;
|
type RehypeProps = Record<string, unknown>;
|
||||||
|
|
||||||
function readAttr(props: RehypeProps | undefined, name: string): string {
|
function readAttr(props: RehypeProps | undefined, name: string): string {
|
||||||
@ -326,42 +305,6 @@ function writeAttr(props: RehypeProps, name: string, value: string | null) {
|
|||||||
props[name] = value;
|
props[name] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// show_file 预览类型:text/code/json/csv/markdown/image/pdf/binary
|
|
||||||
const SHOW_FILE_PREVIEW_TYPES = new Set([
|
|
||||||
'text', 'code', 'json', 'csv', 'markdown', 'image', 'pdf', 'binary'
|
|
||||||
]);
|
|
||||||
|
|
||||||
const SHOW_FILE_EXT_TO_TYPE: Record<string, string> = {
|
|
||||||
'.txt': 'text',
|
|
||||||
'.md': 'markdown', '.markdown': 'markdown',
|
|
||||||
'.json': 'json', '.json5': 'json',
|
|
||||||
'.csv': 'csv', '.tsv': 'csv',
|
|
||||||
'.yaml': 'text', '.yml': 'text', '.toml': 'text',
|
|
||||||
'.ini': 'text', '.cfg': 'text', '.conf': 'text', '.log': 'text',
|
|
||||||
'.js': 'code', '.ts': 'code', '.jsx': 'code', '.tsx': 'code',
|
|
||||||
'.vue': 'code', '.py': 'code', '.rb': 'code', '.go': 'code',
|
|
||||||
'.rs': 'code', '.java': 'code', '.c': 'code', '.h': 'code',
|
|
||||||
'.cpp': 'code', '.hpp': 'code', '.cs': 'code', '.php': 'code',
|
|
||||||
'.swift': 'code', '.kt': 'code', '.sh': 'code', '.bash': 'code',
|
|
||||||
'.html': 'code', '.htm': 'code', '.css': 'code', '.scss': 'code',
|
|
||||||
'.less': 'code', '.xml': 'code', '.svg': 'image', '.sql': 'code',
|
|
||||||
'.png': 'image', '.jpg': 'image', '.jpeg': 'image',
|
|
||||||
'.gif': 'image', '.webp': 'image', '.bmp': 'image', '.ico': 'image',
|
|
||||||
'.avif': 'image',
|
|
||||||
'.pdf': 'pdf',
|
|
||||||
};
|
|
||||||
|
|
||||||
function inferShowFileType(path: string, explicit?: string): string {
|
|
||||||
if (explicit && SHOW_FILE_PREVIEW_TYPES.has(explicit)) return explicit;
|
|
||||||
const lowerPath = path.toLowerCase();
|
|
||||||
const dotIdx = lowerPath.lastIndexOf('.');
|
|
||||||
if (dotIdx >= 0) {
|
|
||||||
const ext = lowerPath.slice(dotIdx);
|
|
||||||
if (SHOW_FILE_EXT_TO_TYPE[ext]) return SHOW_FILE_EXT_TO_TYPE[ext];
|
|
||||||
}
|
|
||||||
return 'binary';
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeShowTagsPlugin() {
|
function normalizeShowTagsPlugin() {
|
||||||
return (tree: any) => {
|
return (tree: any) => {
|
||||||
visit(tree, 'element', (node: any) => {
|
visit(tree, 'element', (node: any) => {
|
||||||
@ -392,47 +335,6 @@ function normalizeShowTagsPlugin() {
|
|||||||
}
|
}
|
||||||
node.properties = props;
|
node.properties = props;
|
||||||
}
|
}
|
||||||
if (node.tagName === 'show_file' || node.tagName === 'show-file') {
|
|
||||||
node.tagName = 'show_file';
|
|
||||||
const props = (node.properties || {}) as RehypeProps;
|
|
||||||
const rawPath = readAttr(props, 'path').trim();
|
|
||||||
const path = rawPath.replace(/^\/+/, '');
|
|
||||||
writeAttr(props, 'path', path);
|
|
||||||
node.properties = props;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拦截 markdown 链接里的 download:// 协议,标记为下载链接。
|
|
||||||
* 输入:[文件名.pdf](download:///output/file.pdf) 或 (download://output/file.pdf)
|
|
||||||
* 输出:<a class="md-download-link" data-path="output/file.pdf" href="download://output/file.pdf">文件名.pdf</a>
|
|
||||||
* 点击事件由 bootstrap.ts 统一拦截。
|
|
||||||
*/
|
|
||||||
function transformDownloadLinksPlugin() {
|
|
||||||
return (tree: any) => {
|
|
||||||
visit(tree, 'element', (node: any) => {
|
|
||||||
if (!node || node.tagName !== 'a' || !node.properties) return;
|
|
||||||
const href = node.properties.href;
|
|
||||||
if (typeof href !== 'string') return;
|
|
||||||
// 兼容 download:///path(三个斜杠,旧写法)和 download://path(两个斜杠,推荐写法)
|
|
||||||
const match = href.match(/^download:\/\/\/?(.*)$/i);
|
|
||||||
if (!match) return;
|
|
||||||
const rawPath = match[1];
|
|
||||||
const path = rawPath.replace(/^\/+/, '');
|
|
||||||
if (!path) return;
|
|
||||||
node.properties.href = `download://${path}`;
|
|
||||||
node.properties['data-path'] = path;
|
|
||||||
node.properties['data-download'] = '1';
|
|
||||||
const existingClass = node.properties.className;
|
|
||||||
if (Array.isArray(existingClass)) {
|
|
||||||
if (!existingClass.includes('md-download-link')) {
|
|
||||||
existingClass.push('md-download-link');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
node.properties.className = ['md-download-link'];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -459,36 +361,14 @@ function wrapMarkdownTablesPlugin() {
|
|||||||
|
|
||||||
const sanitizedSchema: Record<string, any> = {
|
const sanitizedSchema: Record<string, any> = {
|
||||||
...defaultSchema,
|
...defaultSchema,
|
||||||
tagNames: [
|
tagNames: [...(defaultSchema.tagNames || []), 'show_html', 'show_image', 'show-html', 'show-image'],
|
||||||
...(defaultSchema.tagNames || []),
|
|
||||||
'show_html', 'show_image', 'show_file',
|
|
||||||
'show-html', 'show-image', 'show-file'
|
|
||||||
],
|
|
||||||
attributes: {
|
attributes: {
|
||||||
...(defaultSchema.attributes || {}),
|
...(defaultSchema.attributes || {}),
|
||||||
div: [...((defaultSchema.attributes || {}).div || []), 'className', 'data-md-table-scroll'],
|
div: [...((defaultSchema.attributes || {}).div || []), 'className', 'data-md-table-scroll'],
|
||||||
a: [
|
|
||||||
'ariaDescribedBy', 'ariaLabel', 'ariaLabelledBy',
|
|
||||||
'dataFootnoteBackref', 'dataFootnoteRef',
|
|
||||||
'data-path', 'data-download',
|
|
||||||
'href',
|
|
||||||
// className: 合并默认的 data-footnote-backref 和我们的 md-download-link
|
|
||||||
['className', 'data-footnote-backref', 'md-download-link']
|
|
||||||
],
|
|
||||||
show_html: ['ratio', 'js', 'data-encoded', 'data-partial'],
|
show_html: ['ratio', 'js', 'data-encoded', 'data-partial'],
|
||||||
show_image: ['src', 'alt', 'title', 'width', 'height'],
|
show_image: ['src', 'alt', 'title', 'width', 'height'],
|
||||||
show_file: ['path'],
|
|
||||||
'show-html': ['ratio', 'js', 'data-encoded', 'data-partial'],
|
'show-html': ['ratio', 'js', 'data-encoded', 'data-partial'],
|
||||||
'show-image': ['src', 'alt', 'title', 'width', 'height'],
|
'show-image': ['src', 'alt', 'title', 'width', 'height']
|
||||||
'show-file': ['path']
|
|
||||||
},
|
|
||||||
// 允许 <a href="download://..."> 链接
|
|
||||||
protocols: {
|
|
||||||
...(defaultSchema.protocols || {}),
|
|
||||||
href: [
|
|
||||||
...((defaultSchema.protocols || {}).href || []),
|
|
||||||
'download'
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -499,7 +379,6 @@ const markdownProcessor = unified()
|
|||||||
.use(remarkRehype, { allowDangerousHtml: true })
|
.use(remarkRehype, { allowDangerousHtml: true })
|
||||||
.use(rehypeRaw)
|
.use(rehypeRaw)
|
||||||
.use(normalizeShowTagsPlugin)
|
.use(normalizeShowTagsPlugin)
|
||||||
.use(transformDownloadLinksPlugin)
|
|
||||||
.use(wrapMarkdownTablesPlugin)
|
.use(wrapMarkdownTablesPlugin)
|
||||||
.use(rehypeSanitize, sanitizedSchema)
|
.use(rehypeSanitize, sanitizedSchema)
|
||||||
.use(rehypeStringify, { allowDangerousHtml: false });
|
.use(rehypeStringify, { allowDangerousHtml: false });
|
||||||
@ -589,9 +468,7 @@ export function renderMarkdown(text: string, isStreaming = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeText = transformShowFileBlocks(
|
const safeText = transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming));
|
||||||
transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming))
|
|
||||||
);
|
|
||||||
let html = '';
|
let html = '';
|
||||||
try {
|
try {
|
||||||
html = renderMarkdownToHtml(safeText);
|
html = renderMarkdownToHtml(safeText);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { debugLog, goalModeDebugLog } from '../app/methods/common';
|
import { debugLog } from '../app/methods/common';
|
||||||
|
|
||||||
const debugNotifyLog = (...args: any[]) => {
|
const debugNotifyLog = (...args: any[]) => {
|
||||||
void args;
|
void args;
|
||||||
@ -55,7 +55,7 @@ export const useTaskStore = defineStore('task', {
|
|||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
hasActiveTask: (state) => state.currentTaskId !== null && state.taskStatus === 'running',
|
hasActiveTask: (state) => state.currentTaskId !== null && state.taskStatus === 'running',
|
||||||
isTaskCompleted: (state) => ['succeeded', 'failed', 'canceled', 'stopped'].includes(state.taskStatus)
|
isTaskCompleted: (state) => ['succeeded', 'failed', 'canceled'].includes(state.taskStatus)
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
@ -206,12 +206,6 @@ export const useTaskStore = defineStore('task', {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 更新任务状态
|
// 更新任务状态
|
||||||
goalModeDebugLog('taskStore.poll:status', {
|
|
||||||
taskId,
|
|
||||||
from: fromOffset,
|
|
||||||
status: data.status,
|
|
||||||
isTaskCompleted: this.isTaskCompleted,
|
|
||||||
});
|
|
||||||
this.taskStatus = data.status;
|
this.taskStatus = data.status;
|
||||||
this.taskUpdatedAt = data.updated_at;
|
this.taskUpdatedAt = data.updated_at;
|
||||||
const runtimeQueueMessages = Array.isArray(data?.runtime_queued_messages)
|
const runtimeQueueMessages = Array.isArray(data?.runtime_queued_messages)
|
||||||
@ -512,7 +506,6 @@ export const useTaskStore = defineStore('task', {
|
|||||||
this.pollingInFlight = false;
|
this.pollingInFlight = false;
|
||||||
this.pollingWarned = false;
|
this.pollingWarned = false;
|
||||||
this.runtimeQueueSnapshotKey = '';
|
this.runtimeQueueSnapshotKey = '';
|
||||||
goalModeDebugLog('taskStore.stopPolling', { reason, taskStatus: this.taskStatus, currentTaskId: this.currentTaskId });
|
|
||||||
this.currentTaskId = null; // 清除任务 ID
|
this.currentTaskId = null; // 清除任务 ID
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -1530,22 +1530,3 @@ show-html:not([data-rendered='1'])[ratio='3:4'] {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== show_file 预览卡片外层容器 =====
|
|
||||||
// 实际样式由 ShowFileCard.vue 的 scoped style 控制,
|
|
||||||
// 这里只保留对 .show-file-card-root 的最小兜底
|
|
||||||
.show-file-card-root {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== download:// 链接样式 =====
|
|
||||||
a.md-download-link {
|
|
||||||
color: var(--accent-primary);
|
|
||||||
text-decoration: underline;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -346,53 +346,6 @@ class CrudMixin:
|
|||||||
print(f"⌘ 保存对话失败 {conversation_id}: {e}")
|
print(f"⌘ 保存对话失败 {conversation_id}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def mark_latest_user_work_completed(self, conversation_id: str, finished_at: Optional[str] = None) -> bool:
|
|
||||||
"""将对话中最后一条 status 为 working 的用户消息的 work_timer 标记为完成。
|
|
||||||
|
|
||||||
用于任务停止或正常结束后,刷新页面时不再恢复为"工作中"状态。
|
|
||||||
"""
|
|
||||||
if not conversation_id:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
data = self.load_conversation(conversation_id)
|
|
||||||
if not data:
|
|
||||||
return False
|
|
||||||
messages = data.get("messages") or []
|
|
||||||
completed = False
|
|
||||||
now_iso = finished_at or datetime.now().isoformat()
|
|
||||||
for msg in reversed(messages):
|
|
||||||
if not isinstance(msg, dict) or msg.get("role") != "user":
|
|
||||||
continue
|
|
||||||
metadata = msg.get("metadata") or {}
|
|
||||||
timer = metadata.get("work_timer")
|
|
||||||
if not isinstance(timer, dict):
|
|
||||||
continue
|
|
||||||
if timer.get("status") != "working":
|
|
||||||
continue
|
|
||||||
started_at = timer.get("started_at") or msg.get("timestamp") or now_iso
|
|
||||||
try:
|
|
||||||
start_dt = datetime.fromisoformat(str(started_at).replace("Z", "+00:00"))
|
|
||||||
end_dt = datetime.fromisoformat(now_iso.replace("Z", "+00:00"))
|
|
||||||
duration_ms = max(0, int((end_dt - start_dt).total_seconds() * 1000))
|
|
||||||
except Exception:
|
|
||||||
duration_ms = timer.get("duration_ms", 0) or 0
|
|
||||||
timer["status"] = "completed"
|
|
||||||
timer["started_at"] = started_at
|
|
||||||
timer["finished_at"] = now_iso
|
|
||||||
timer["duration_ms"] = duration_ms
|
|
||||||
msg["metadata"] = metadata
|
|
||||||
completed = True
|
|
||||||
break
|
|
||||||
if not completed:
|
|
||||||
return False
|
|
||||||
data["updated_at"] = datetime.now().isoformat()
|
|
||||||
self._save_conversation_file(conversation_id, data)
|
|
||||||
self._update_index(conversation_id, data)
|
|
||||||
return True
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"⌘ 标记用户工作完成失败 {conversation_id}: {exc}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def update_project_snapshot(
|
def update_project_snapshot(
|
||||||
self,
|
self,
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user