Compare commits

...

2 Commits

Author SHA1 Message Date
e4a663594d feat: Android App 原生 PDF 预览与前端 show_file 卡片
- 集成 AndroidPdfViewer (mhiew fork) 实现不依赖浏览器的 PDF 预览
- 新增 PdfPreviewActivity 与 JS Bridge (AndroidPdfBridge)
- ShowFileCard 在 Android App 内对 PDF 显示「预览」按钮,不内嵌 iframe
- 新增 /api/file/content 后端接口用于文件内容 inline 预览
- 前端支持 <show_file> 标签与 download:// 链接渲染
- 更新 Android 版本号至 1.0.35
2026-06-24 00:06:10 +08:00
53b3669f1d fix(stop): 强停止按钮两段式逻辑与 work_timer 持久化 2026-06-23 19:27:50 +08:00
22 changed files with 1595 additions and 65 deletions

View File

@ -1,5 +1,8 @@
# App 更新说明
# 1.0.35
- 新增 PDF 文件原生预览:集成 AndroidPdfViewer点击卡片「预览」按钮可在 App 内直接查看 PDF不再依赖浏览器
# 1.0.32
- 识别引擎改为预初始化模型下载完成后立即在后台加载用户点击时直接可用不再每次点击等3-5秒初始化
- startRecordingInternal 增加详细日志,便于排查录音问题

View File

@ -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/ 目录即可

View File

@ -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>

View File

@ -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?) {

View File

@ -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)
}
}
}

View File

@ -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>

View File

@ -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` 控制覆盖/追加)

View File

@ -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` 控制覆盖/追加)

View File

@ -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
)

View File

@ -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:
# 【新增】错误时确保对话状态不丢失

View File

@ -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)})

View 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

View File

@ -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,

View File

@ -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;

View File

@ -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;
}
}
};

View File

@ -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,

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
});
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>

View File

@ -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 });
});

View File

@ -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);

View File

@ -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
},

View File

@ -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;
}
}

View File

@ -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,