Compare commits
2 Commits
e90610ed05
...
e4a663594d
| Author | SHA1 | Date | |
|---|---|---|---|
| e4a663594d | |||
| 53b3669f1d |
@ -1,5 +1,8 @@
|
||||
# App 更新说明
|
||||
|
||||
# 1.0.35
|
||||
- 新增 PDF 文件原生预览:集成 AndroidPdfViewer,点击卡片「预览」按钮可在 App 内直接查看 PDF,不再依赖浏览器
|
||||
|
||||
# 1.0.32
|
||||
- 识别引擎改为预初始化:模型下载完成后立即在后台加载,用户点击时直接可用(不再每次点击等3-5秒初始化)
|
||||
- startRecordingInternal 增加详细日志,便于排查录音问题
|
||||
|
||||
@ -16,8 +16,8 @@ android {
|
||||
applicationId = "com.cyjai.agent"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 36
|
||||
versionName = "1.0.34"
|
||||
versionCode = 37
|
||||
versionName = "1.0.35"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@ -66,6 +66,9 @@ dependencies {
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0")
|
||||
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/)
|
||||
// 下载地址:https://huggingface.co/csukuangfj/sherpa-onnx-libs/tree/main/android/aar
|
||||
// 下载最新 sherpa-onnx-*.aar 放到 app/libs/ 目录即可
|
||||
|
||||
@ -27,6 +27,12 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".PdfPreviewActivity"
|
||||
android:exported="false"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|uiMode" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@ -11,6 +11,7 @@ import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.view.View
|
||||
import android.content.Context
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.PermissionRequest
|
||||
@ -100,6 +101,7 @@ class MainActivity : AppCompatActivity() {
|
||||
webView.clearCache(true)
|
||||
|
||||
webView.addJavascriptInterface(ThemeBridge(), "AndroidThemeBridge")
|
||||
webView.addJavascriptInterface(PdfPreviewBridge(), "AndroidPdfBridge")
|
||||
|
||||
// 语音识别桥接(立即注册,前端始终可检测到;引擎在首次使用时懒初始化)
|
||||
voiceBridge = VoiceBridge(this@MainActivity, webView)
|
||||
@ -367,6 +369,21 @@ class MainActivity : AppCompatActivity() {
|
||||
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 {
|
||||
@JavascriptInterface
|
||||
fun onThemeChanged(theme: String?) {
|
||||
|
||||
@ -0,0 +1,150 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
<?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,6 +82,30 @@
|
||||
|
||||
**图片检索流程**: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 文件操作
|
||||
|
||||
- `write_file`:写入文件(`append` 控制覆盖/追加)
|
||||
|
||||
@ -82,6 +82,30 @@
|
||||
|
||||
**图片检索流程**: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 文件操作
|
||||
|
||||
- `write_file`:写入文件(`append` 控制覆盖/追加)
|
||||
|
||||
@ -12,6 +12,7 @@ from flask import Blueprint, jsonify, request, session, send_file
|
||||
from werkzeug.utils import secure_filename
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
import secrets
|
||||
import mimetypes
|
||||
|
||||
from config import THINKING_FAST_INTERVAL, MAX_UPLOAD_SIZE, OUTPUT_FORMATS
|
||||
from modules.personalization_manager import (
|
||||
@ -242,3 +243,73 @@ def download_folder_api(terminal: WebTerminal, workspace: UserWorkspace, usernam
|
||||
as_attachment=True,
|
||||
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,6 +46,7 @@ from modules.personalization_manager import (
|
||||
THINKING_INTERVAL_MIN,
|
||||
THINKING_INTERVAL_MAX,
|
||||
)
|
||||
from modules.sub_agent.state import TERMINAL_STATUSES as SUB_AGENT_TERMINAL_STATUSES
|
||||
from modules.upload_security import UploadSecurityError
|
||||
from modules.user_manager import UserWorkspace
|
||||
from modules.usage_tracker import QUOTA_DEFAULTS
|
||||
@ -180,22 +181,85 @@ def process_message_task(terminal: WebTerminal, message: str, images, sender, cl
|
||||
|
||||
entry = get_stop_flag(client_sid, username)
|
||||
if not isinstance(entry, dict):
|
||||
entry = {'stop': False, 'task': None, 'terminal': None}
|
||||
entry = {'stop': False, 'task': None, 'terminal': None, 'loop': None}
|
||||
entry['stop'] = False
|
||||
entry['task'] = task
|
||||
entry['terminal'] = terminal
|
||||
entry['loop'] = loop
|
||||
set_stop_flag(client_sid, username, entry)
|
||||
|
||||
try:
|
||||
loop.run_until_complete(task)
|
||||
except asyncio.CancelledError:
|
||||
debug_log(f"任务 {client_sid} 被成功取消")
|
||||
sender('task_stopped', {
|
||||
'message': '任务已停止',
|
||||
'reason': 'user_requested'
|
||||
})
|
||||
debug_log(f"[ChatFlow] 任务被成功取消: client_sid={client_sid}")
|
||||
# 检测是否仍有后台任务在跑,通知前端保持停止按钮
|
||||
has_running_sub_agents = False
|
||||
has_running_background_commands = False
|
||||
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)
|
||||
|
||||
|
||||
loop.close()
|
||||
except Exception as e:
|
||||
# 【新增】错误时确保对话状态不丢失
|
||||
|
||||
@ -22,6 +22,8 @@ from server.utils_common import debug_log, log_conn_diag
|
||||
from utils.host_workspace_debug import write_host_workspace_debug
|
||||
from config import DATA_DIR, WORKSPACE_SKILLS_DIRNAME
|
||||
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)
|
||||
@ -51,6 +53,7 @@ class TaskRecord:
|
||||
"next_event_idx",
|
||||
"runtime_pending_queue",
|
||||
"runtime_guidance_queue",
|
||||
"last_cancel_at",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@ -87,6 +90,7 @@ class TaskRecord:
|
||||
self.next_event_idx: int = 0
|
||||
self.runtime_pending_queue: List[Dict[str, Any]] = []
|
||||
self.runtime_guidance_queue: List[str] = []
|
||||
self.last_cancel_at: Optional[float] = None
|
||||
|
||||
class TaskManager:
|
||||
"""线程内存版任务管理器,后续可替换为 Redis/DB。"""
|
||||
@ -202,26 +206,125 @@ class TaskManager:
|
||||
def cancel_task(self, username: str, task_id: str) -> bool:
|
||||
rec = self.get_task(username, task_id)
|
||||
if not rec:
|
||||
debug_log(f"[TaskCancel] cancel_task 找不到任务: task_id={task_id}")
|
||||
return False
|
||||
rec.stop_requested = True
|
||||
# 标记停止标志;chat_flow 会检测 stop_flags
|
||||
|
||||
# 立即快照进入时的状态,后续所有两段式判断都用它。
|
||||
# _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)
|
||||
if not isinstance(entry, dict):
|
||||
entry = {'stop': False, 'task': None, 'terminal': None}
|
||||
stop_flags[task_id] = entry
|
||||
entry['stop'] = True
|
||||
# 注释掉直接取消任务,改为通过停止标志让任务内部处理
|
||||
# try:
|
||||
# if entry.get('task') and hasattr(entry['task'], "cancel"):
|
||||
# entry['task'].cancel()
|
||||
# except Exception:
|
||||
# pass
|
||||
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
|
||||
|
||||
# 2. 停止目标模式
|
||||
workspace = None
|
||||
if rec.workspace_id:
|
||||
try:
|
||||
_, workspace = get_user_resources(username, rec.workspace_id)
|
||||
if workspace:
|
||||
gsm = GoalStateManager(workspace.data_dir)
|
||||
if gsm.is_active():
|
||||
gsm.mark_stopped(REASON_USER_CANCEL)
|
||||
debug_log(f"[Goal] 用户取消任务 {task_id},同步停止工作区目标模式")
|
||||
except Exception as exc:
|
||||
debug_log(f"[Goal] 取消任务时停止目标模式失败: {exc}")
|
||||
|
||||
# 3. 丢弃已经引导的内容(预输入队列保持不变,正常结束后再插入)
|
||||
with self._lock:
|
||||
rec.status = "cancel_requested"
|
||||
rec.runtime_guidance_queue = []
|
||||
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()
|
||||
debug_log(f"[Task] 任务 {task_id} 已取消,事件队列已清空")
|
||||
debug_log(
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
@ -500,6 +603,89 @@ class TaskManager:
|
||||
return items
|
||||
|
||||
# ---- 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]):
|
||||
if isinstance(data, dict):
|
||||
data = dict(data)
|
||||
@ -735,9 +921,66 @@ class TaskManager:
|
||||
|
||||
# 结束状态
|
||||
canceled_flag = rec.stop_requested or stop_hint or bool(stop_flags.get(rec.task_id, {}).get("stop"))
|
||||
with self._lock:
|
||||
rec.status = "canceled" if canceled_flag else "succeeded"
|
||||
rec.updated_at = time.time()
|
||||
if canceled_flag:
|
||||
# 用户取消时,根据是否还有后台任务决定状态:
|
||||
# - 有后台任务:保持 cancel_requested,等待用户第二下清理
|
||||
# - 无后台任务:直接 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:
|
||||
debug_log(f"[Task] 后台任务失败: {exc}")
|
||||
self._append_event(rec, "error", {"message": str(exc)})
|
||||
|
||||
1
static/icons/file-down.svg
Normal file
1
static/icons/file-down.svg
Normal file
@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 328 B |
@ -851,8 +851,11 @@ const VirtualMonitorSurface = defineAsyncComponent(
|
||||
const PathAuthorizationDialog = defineAsyncComponent(
|
||||
() => import('./components/overlay/PathAuthorizationDialog.vue')
|
||||
);
|
||||
|
||||
const tutorialStore = useTutorialStore();
|
||||
|
||||
|
||||
|
||||
const mobilePanelIcon = new URL('../icons/align-left.svg', import.meta.url).href;
|
||||
const detectAppShell = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
@ -870,6 +873,7 @@ onMounted(() => {
|
||||
window.setTimeout(() => {
|
||||
isAppShell.value = detectAppShell();
|
||||
}, 300);
|
||||
|
||||
});
|
||||
const mobileMenuIcons = {
|
||||
workspace: new URL('../icons/folder.svg', import.meta.url).href,
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
// @ts-nocheck
|
||||
import katex from 'katex';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { createApp } from 'vue';
|
||||
import ShowFileCard from '../components/chat/ShowFileCard.vue';
|
||||
|
||||
let showImageRenderSeq = 0;
|
||||
let showImageDebugLogCount = 0;
|
||||
@ -430,10 +432,17 @@ function renderShowImages(root: ParentNode | null = document) {
|
||||
const htmlNodes = Array.from(
|
||||
root.querySelectorAll('show_html:not([data-rendered]), show-html:not([data-rendered])')
|
||||
);
|
||||
if (htmlNodes.length > 0) {
|
||||
const fileNodes = Array.from(
|
||||
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();
|
||||
}
|
||||
if (!verbose && imageNodes.length === 0 && htmlNodes.length === 0) return;
|
||||
if (!verbose && imageNodes.length === 0 && htmlNodes.length === 0 && fileNodes.length === 0 && !hasDownloadLinks) return;
|
||||
|
||||
const rootNode = root as Node;
|
||||
debugShowImageLog('render:start', {
|
||||
@ -444,6 +453,7 @@ function renderShowImages(root: ParentNode | null = document) {
|
||||
: `nodeType:${rootNode?.nodeType}`,
|
||||
pendingImageCount: imageNodes.length,
|
||||
pendingHtmlCount: htmlNodes.length,
|
||||
pendingFileCount: fileNodes.length,
|
||||
orderBefore: collectShowImageOrder(root)
|
||||
});
|
||||
|
||||
@ -931,6 +941,16 @@ 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', {
|
||||
renderId,
|
||||
orderAfter: collectShowImageOrder(root)
|
||||
@ -1222,15 +1242,15 @@ export function setupShowImageObserver() {
|
||||
const hasShowImageRelatedMutation = mutations.some((mutation) => {
|
||||
const targetIsShowImage =
|
||||
mutation.target instanceof Element &&
|
||||
['show_image', 'show_html', 'show-image', 'show-html'].includes(
|
||||
['show_image', 'show_html', 'show_file', 'show-image', 'show-html', 'show-file'].includes(
|
||||
mutation.target.tagName.toLowerCase()
|
||||
);
|
||||
const addedHasShowTag = Array.from(mutation.addedNodes).some(
|
||||
(n) =>
|
||||
n instanceof Element &&
|
||||
(['show_image', 'show_html', 'show-image', 'show-html'].includes(
|
||||
(['show_image', 'show_html', 'show_file', 'show-image', 'show-html', 'show-file'].includes(
|
||||
n.tagName.toLowerCase()
|
||||
) || !!n.querySelector?.('show_image,show_html,show-image,show-html'))
|
||||
) || !!n.querySelector?.('show_image,show_html,show_file,show-image,show-html,show-file,a.md-download-link,a[data-download="1"]'))
|
||||
);
|
||||
return targetIsShowImage || addedHasShowTag;
|
||||
});
|
||||
@ -1325,6 +1345,94 @@ 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') {
|
||||
window.katex = katex;
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import { debugLog } from '../common';
|
||||
import { debugLog, goalModeDebugLog } from '../common';
|
||||
import { useTaskStore } from '../../../stores/task';
|
||||
import { useModelStore } from '../../../stores/model';
|
||||
import {
|
||||
@ -335,7 +335,13 @@ export const sendMethods = {
|
||||
},
|
||||
async stopTask() {
|
||||
console.log('[DEBUG_AWAITING] ===== stopTask =====');
|
||||
if (this._stopTaskRunning) {
|
||||
goalModeDebugLog('stopTask:debounce-rejected', { stopRequested: this.stopRequested });
|
||||
return;
|
||||
}
|
||||
this._stopTaskRunning = true;
|
||||
if (this.compressionInProgress) {
|
||||
this._stopTaskRunning = false;
|
||||
this.uiPushToast({
|
||||
title: '对话自动压缩中',
|
||||
message: '压缩进行中,当前不可停止任务',
|
||||
@ -345,7 +351,16 @@ export const sendMethods = {
|
||||
}
|
||||
|
||||
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) {
|
||||
this._stopTaskRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@ -368,15 +383,19 @@ export const sendMethods = {
|
||||
|
||||
if (taskStore.currentTaskId) {
|
||||
await taskStore.cancelTask();
|
||||
// 立即停止轮询,防止旧任务的目标模式事件在切换对话后仍被处理
|
||||
taskStore.stopPolling('user_stop');
|
||||
}
|
||||
|
||||
// 等待后端确认
|
||||
// 等待后端确认;轮询继续,task_stopped 事件到达后会由 taskStore 自动停止轮询
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const shouldKeepBusy =
|
||||
Boolean(taskStore.currentTaskId) &&
|
||||
['running', 'pending', 'cancel_requested'].includes(String(taskStore.taskStatus));
|
||||
['running', 'pending', 'cancel_requested', 'canceled'].includes(String(taskStore.taskStatus));
|
||||
|
||||
goalModeDebugLog('stopTask:try-end', {
|
||||
currentTaskId: taskStore.currentTaskId,
|
||||
taskStatus: taskStore.taskStatus,
|
||||
shouldKeepBusy,
|
||||
streamingMessage: this.streamingMessage,
|
||||
});
|
||||
|
||||
// 清理前端状态
|
||||
this.clearPendingTools('user_stop');
|
||||
@ -414,8 +433,14 @@ export const sendMethods = {
|
||||
const { useTaskStore } = await import('../../../stores/task');
|
||||
const taskStore = useTaskStore();
|
||||
const shouldKeepBusy =
|
||||
Boolean(taskStore.currentTaskId) &&
|
||||
['running', 'pending', 'cancel_requested'].includes(String(taskStore.taskStatus));
|
||||
['running', 'pending', 'cancel_requested', 'canceled'].includes(String(taskStore.taskStatus));
|
||||
|
||||
goalModeDebugLog('stopTask:catch', {
|
||||
error: String(error),
|
||||
currentTaskId: taskStore.currentTaskId,
|
||||
taskStatus: taskStore.taskStatus,
|
||||
shouldKeepBusy,
|
||||
});
|
||||
|
||||
// 即使失败也清理状态
|
||||
this.clearPendingTools('user_stop');
|
||||
@ -435,14 +460,15 @@ export const sendMethods = {
|
||||
}
|
||||
|
||||
this.uiPushToast({
|
||||
title: '停止失败',
|
||||
message: '任务可能仍在运行,请刷新页面',
|
||||
type: 'warning'
|
||||
title: '停止请求已发送',
|
||||
message: '若任务未停止,请再次点击停止按钮',
|
||||
type: 'info'
|
||||
});
|
||||
} finally {
|
||||
// 确保清除 dropToolEvents 和 stopRequested 标志
|
||||
this.dropToolEvents = false;
|
||||
this.stopRequested = false;
|
||||
this._stopTaskRunning = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import { debugLog } from '../common';
|
||||
import { debugLog, goalModeDebugLog } from '../common';
|
||||
import { useTaskStore } from '../../../stores/task';
|
||||
import { getMessageVisibility, messageStartsWork } from '../../../utils/messageVisibility';
|
||||
import {
|
||||
@ -106,10 +106,14 @@ export const lifecycleMethods = {
|
||||
}
|
||||
}
|
||||
// 检查事件 task_id 是否仍是当前正在接管的任务,防止切换/新建对话后旧轮询的在途响应串写。
|
||||
// task_stopped / task_complete / error 属于任务终态事件,即使 currentTaskId 已被清理
|
||||
//(例如用户点击停止后 stopPolling 清空了 ID),只要 conversation_id 匹配就应该处理。
|
||||
const isTerminalTaskEvent = ['task_complete', 'task_stopped', 'error'].includes(eventType);
|
||||
if (
|
||||
!crossConversationAllowed.has(eventType) &&
|
||||
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', {
|
||||
eventType,
|
||||
@ -441,6 +445,7 @@ export const lifecycleMethods = {
|
||||
});
|
||||
},
|
||||
handleTaskStopped(data: any, eventIdx: number) {
|
||||
goalModeDebugLog('handleTaskStopped:entered', { eventIdx, data });
|
||||
jsonDebug('handleTaskStopped:before', {
|
||||
eventIdx,
|
||||
data,
|
||||
@ -450,28 +455,67 @@ export const lifecycleMethods = {
|
||||
});
|
||||
debugLog('[TaskPolling] 任务已停止, idx:', eventIdx, data);
|
||||
|
||||
this.markLatestUserWorkCompleted();
|
||||
const hasRunningSubAgents = !!data?.has_running_sub_agents;
|
||||
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.streamingMessage = false;
|
||||
this.taskInProgress = false;
|
||||
this.stopRequested = false;
|
||||
this.waitingForSubAgent = false;
|
||||
this.waitingForBackgroundCommand = false;
|
||||
this.stopWaitingTaskProbe();
|
||||
|
||||
if (typeof this.clearPendingTools === 'function') {
|
||||
this.clearPendingTools('task_stopped');
|
||||
// 智能体工作本身已结束,无论是否还有后台任务,先把 work_timer 标记为完成
|
||||
this.markLatestUserWorkCompleted();
|
||||
|
||||
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);
|
||||
setTimeout(() => this.refreshRunningWorkspaceTasks?.(), 0);
|
||||
this.$forceUpdate();
|
||||
|
||||
this.clearTaskState();
|
||||
this.$nextTick(() => {
|
||||
if (typeof this.tryAutoSendRuntimeQueuedMessages === 'function') {
|
||||
this.tryAutoSendRuntimeQueuedMessages('task_stopped');
|
||||
}
|
||||
});
|
||||
jsonDebug('handleTaskStopped:after', {
|
||||
taskInProgress: this.taskInProgress,
|
||||
streamingMessage: this.streamingMessage,
|
||||
|
||||
497
static/src/components/chat/ShowFileCard.vue
Normal file
497
static/src/components/chat/ShowFileCard.vue
Normal file
@ -0,0 +1,497 @@
|
||||
<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,7 +1573,22 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
// 任务停止
|
||||
ctx.socket.on('task_stopped', (data) => {
|
||||
socketLog('任务已停止:', data.message);
|
||||
ctx.taskInProgress = false;
|
||||
const hasRunningSubAgents = !!data?.has_running_sub_agents;
|
||||
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 });
|
||||
});
|
||||
|
||||
|
||||
@ -283,6 +283,27 @@ function transformShowImageBlocks(raw: string) {
|
||||
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>;
|
||||
|
||||
function readAttr(props: RehypeProps | undefined, name: string): string {
|
||||
@ -305,6 +326,42 @@ function writeAttr(props: RehypeProps, name: string, value: string | null) {
|
||||
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() {
|
||||
return (tree: any) => {
|
||||
visit(tree, 'element', (node: any) => {
|
||||
@ -335,6 +392,47 @@ function normalizeShowTagsPlugin() {
|
||||
}
|
||||
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'];
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -361,14 +459,36 @@ function wrapMarkdownTablesPlugin() {
|
||||
|
||||
const sanitizedSchema: Record<string, any> = {
|
||||
...defaultSchema,
|
||||
tagNames: [...(defaultSchema.tagNames || []), 'show_html', 'show_image', 'show-html', 'show-image'],
|
||||
tagNames: [
|
||||
...(defaultSchema.tagNames || []),
|
||||
'show_html', 'show_image', 'show_file',
|
||||
'show-html', 'show-image', 'show-file'
|
||||
],
|
||||
attributes: {
|
||||
...(defaultSchema.attributes || {}),
|
||||
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_image: ['src', 'alt', 'title', 'width', 'height'],
|
||||
show_file: ['path'],
|
||||
'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'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
@ -379,6 +499,7 @@ const markdownProcessor = unified()
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeRaw)
|
||||
.use(normalizeShowTagsPlugin)
|
||||
.use(transformDownloadLinksPlugin)
|
||||
.use(wrapMarkdownTablesPlugin)
|
||||
.use(rehypeSanitize, sanitizedSchema)
|
||||
.use(rehypeStringify, { allowDangerousHtml: false });
|
||||
@ -468,7 +589,9 @@ export function renderMarkdown(text: string, isStreaming = false) {
|
||||
}
|
||||
}
|
||||
|
||||
const safeText = transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming));
|
||||
const safeText = transformShowFileBlocks(
|
||||
transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming))
|
||||
);
|
||||
let html = '';
|
||||
try {
|
||||
html = renderMarkdownToHtml(safeText);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { defineStore } from 'pinia';
|
||||
import { debugLog } from '../app/methods/common';
|
||||
import { debugLog, goalModeDebugLog } from '../app/methods/common';
|
||||
|
||||
const debugNotifyLog = (...args: any[]) => {
|
||||
void args;
|
||||
@ -55,7 +55,7 @@ export const useTaskStore = defineStore('task', {
|
||||
|
||||
getters: {
|
||||
hasActiveTask: (state) => state.currentTaskId !== null && state.taskStatus === 'running',
|
||||
isTaskCompleted: (state) => ['succeeded', 'failed', 'canceled'].includes(state.taskStatus)
|
||||
isTaskCompleted: (state) => ['succeeded', 'failed', 'canceled', 'stopped'].includes(state.taskStatus)
|
||||
},
|
||||
|
||||
actions: {
|
||||
@ -206,6 +206,12 @@ export const useTaskStore = defineStore('task', {
|
||||
}
|
||||
|
||||
// 更新任务状态
|
||||
goalModeDebugLog('taskStore.poll:status', {
|
||||
taskId,
|
||||
from: fromOffset,
|
||||
status: data.status,
|
||||
isTaskCompleted: this.isTaskCompleted,
|
||||
});
|
||||
this.taskStatus = data.status;
|
||||
this.taskUpdatedAt = data.updated_at;
|
||||
const runtimeQueueMessages = Array.isArray(data?.runtime_queued_messages)
|
||||
@ -506,6 +512,7 @@ export const useTaskStore = defineStore('task', {
|
||||
this.pollingInFlight = false;
|
||||
this.pollingWarned = false;
|
||||
this.runtimeQueueSnapshotKey = '';
|
||||
goalModeDebugLog('taskStore.stopPolling', { reason, taskStatus: this.taskStatus, currentTaskId: this.currentTaskId });
|
||||
this.currentTaskId = null; // 清除任务 ID
|
||||
},
|
||||
|
||||
|
||||
@ -1530,3 +1530,22 @@ show-html:not([data-rendered='1'])[ratio='3:4'] {
|
||||
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,6 +346,53 @@ class CrudMixin:
|
||||
print(f"⌘ 保存对话失败 {conversation_id}: {e}")
|
||||
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(
|
||||
self,
|
||||
conversation_id: str,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user