feat: 上传文件/图片路径提示 + 文件树去 emoji + CSV 预览优化

This commit is contained in:
JOJO 2026-06-24 20:44:26 +08:00
parent d1a9a51bb8
commit f3e166e258
7 changed files with 158 additions and 79 deletions

View File

@ -103,6 +103,7 @@
- 用户说「给我这个文件」「下载 xxx」→ 用下载链接
- 用户说「看一下这个文件」「展示 xxx 的内容」→ 用 `<show_file>` 卡片
- 生成报告/数据文件后想展示成果 → 用 `<show_file>` 卡片
- 用户让你处理、分析、修改文件,或任务可能产生结果文件时 → 完成后主动用 `<show_file>` 卡片或下载链接展示结果,不要等用户索要
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。

View File

@ -103,6 +103,7 @@
- 用户说「给我这个文件」「下载 xxx」→ 用下载链接
- 用户说「看一下这个文件」「展示 xxx 的内容」→ 用 `<show_file>` 卡片
- 生成报告/数据文件后想展示成果 → 用 `<show_file>` 卡片
- 用户让你处理、分析、修改文件,或任务可能产生结果文件时 → 完成后主动用 `<show_file>` 卡片或下载链接展示结果,不要等用户索要
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。

View File

@ -9,3 +9,5 @@
```
{file_tree}
```
{recent_uploads}

View File

@ -174,6 +174,31 @@ def _user_message_ui_defaults(source: str, *, auto_user_message_event: bool = Fa
return {"visibility": "chat", "starts_work": normalized == "user" and not auto_user_message_event}
def _extract_media_path(item: Any) -> str:
if isinstance(item, dict):
return str(item.get("path") or "").strip()
return str(item or "").strip()
def _build_media_paths_message(images: Optional[List[Any]], videos: Optional[List[Any]]) -> Optional[str]:
"""为本次消息附带的图片/视频生成一条 hidden user 消息文本。"""
paths: List[str] = []
for item in images or []:
path = _extract_media_path(item)
if path and path not in paths:
paths.append(path)
for item in videos or []:
path = _extract_media_path(item)
if path and path not in paths:
paths.append(path)
if not paths:
return None
lines = ["[系统通知|media_paths]", "本次消息附带的媒体文件路径:"]
for path in paths:
lines.append(f"- `{path}`")
return "\n".join(lines)
def _should_skip_versioning_for_message(
*,
message: str,
@ -920,6 +945,19 @@ async def handle_task_with_sender(
}
)
if not auto_user_message_event:
media_paths_message = _build_media_paths_message(images, videos)
if media_paths_message:
web_terminal.context_manager.add_conversation(
"user",
media_paths_message,
metadata={
"source": "media_paths",
"message_source": "media_paths",
"visibility": "hidden",
"starts_work": False,
"hidden": True,
}
)
try:
sender(
'user_message',

View File

@ -22,7 +22,7 @@
</div>
</div>
<div class="sfc-preview" v-if="canPreview">
<div class="sfc-preview" :class="{ 'sfc-preview--csv': fileType === 'csv' }" v-if="canPreview">
<div class="sfc-loading" v-if="loading">
<span class="sfc-spinner"></span>
<span>加载中...</span>
@ -35,7 +35,7 @@
</template>
<template v-else-if="fileType === 'csv'">
<div class="sfc-csv-scroll">
<div class="sfc-csv-scroll" :class="{ 'sfc-csv-scroll--loading': loading }">
<table class="sfc-csv-table">
<thead>
<tr>
@ -83,6 +83,24 @@ import Prism from 'prismjs';
import { renderMarkdown } from '../../composables/useMarkdownRenderer';
import PdfPreview from './PdfPreview.vue';
// fetch
const SHOW_FILE_CONTENT_CACHE = new Map<string, { content: string; ts: number }>();
const SHOW_FILE_CONTENT_CACHE_TTL_MS = 5 * 60 * 1000;
function getCachedShowFileContent(path: string): string | null {
const cached = SHOW_FILE_CONTENT_CACHE.get(path);
if (!cached) return null;
if (Date.now() - cached.ts > SHOW_FILE_CONTENT_CACHE_TTL_MS) {
SHOW_FILE_CONTENT_CACHE.delete(path);
return null;
}
return cached.content;
}
function setCachedShowFileContent(path: string, content: string) {
SHOW_FILE_CONTENT_CACHE.set(path, { content, ts: Date.now() });
}
defineOptions({ name: 'ShowFileCard' });
const props = defineProps<{
@ -206,8 +224,19 @@ function buildContentUrl() {
}
async function loadContent() {
loading.value = true;
if (!canPreview.value) return;
error.value = '';
// fetch
if (!['image', 'pdf'].includes(fileType.value)) {
const cached = getCachedShowFileContent(props.path);
if (cached !== null) {
rawContent.value = cached;
return;
}
}
loading.value = true;
try {
if (['image', 'pdf'].includes(fileType.value)) {
contentUrl.value = buildContentUrl();
@ -219,7 +248,9 @@ async function loadContent() {
error.value = msg || '加载失败';
return;
}
rawContent.value = await resp.text();
const content = await resp.text();
rawContent.value = content;
setCachedShowFileContent(props.path, content);
}
} catch (e) {
error.value = (e as Error).message || '网络错误';
@ -362,6 +393,7 @@ onMounted(() => {
.sfc-preview {
border-top: 1px solid var(--border-default);
max-height: 360px;
min-height: 160px;
overflow: auto;
position: relative;
background: var(--surface-base);
@ -369,6 +401,12 @@ onMounted(() => {
scrollbar-color: var(--text-muted) transparent;
}
.sfc-preview--csv {
overflow: auto;
display: block;
padding: 0;
}
.sfc-preview::-webkit-scrollbar {
width: 8px;
}
@ -423,14 +461,24 @@ onMounted(() => {
}
.sfc-csv-scroll {
overflow: auto;
max-height: 350px;
overflow-x: auto;
overflow-y: hidden;
margin: 0;
padding: 0;
}
.sfc-csv-scroll--loading {
min-height: 160px;
}
.sfc-csv-table {
width: 100%;
width: max-content;
min-width: 100%;
border-collapse: collapse;
border-spacing: 0;
font-size: 12px;
margin: 0;
padding: 0;
background: var(--surface-soft);
}
.sfc-csv-table th,
@ -444,8 +492,10 @@ onMounted(() => {
.sfc-csv-table th {
background: var(--surface-soft);
font-weight: 600;
position: sticky;
top: 0;
}
.sfc-csv-table td {
background: var(--surface-base);
}
.sfc-csv-meta {

View File

@ -74,9 +74,56 @@ AUTO_SHALLOW_TOOL_WHITELIST = {
}
MAX_RECENT_UPLOADS = 10
class MessageMixin:
"""ContextManager message mixin 能力 mixin。"""
def _build_recent_uploads_block(self, max_items: int = MAX_RECENT_UPLOADS) -> str:
"""扫描工作区 .agents/user_upload 目录,按修改时间倒序生成最近上传文件列表。"""
try:
project_path = Path(getattr(self, "project_path", ".") or ".").expanduser().resolve()
uploads_dir = project_path / ".agents" / "user_upload"
if not uploads_dir.is_dir():
return ""
files = []
for item in uploads_dir.iterdir():
if not item.is_file():
continue
try:
stat = item.stat()
files.append((item, stat.st_mtime, stat.st_size))
except Exception:
continue
if not files:
return ""
files.sort(key=lambda x: x[1], reverse=True)
lines = ["- **最近上传的文件**"]
for item, mtime, size in files[:max_items]:
try:
rel_path = item.relative_to(project_path)
rel_path_str = str(rel_path).replace("\\", "/")
except Exception:
rel_path_str = str(item.name)
try:
dt = datetime.fromtimestamp(mtime)
time_str = dt.strftime("%Y-%m-%d %H:%M")
except Exception:
time_str = ""
size_str = ""
if size >= 1024 * 1024:
size_str = f"{size / 1024 / 1024:.1f} MB"
elif size >= 1024:
size_str = f"{size / 1024:.1f} KB"
else:
size_str = f"{size} B"
meta = f"{size_str}, {time_str}".strip(", ")
lines.append(f" - `{rel_path_str}` ({meta})")
return "\n\n" + "\n".join(lines)
except Exception:
return ""
def add_conversation(
self,
role: str,
@ -265,6 +312,7 @@ class MessageMixin:
"- **资源限制**{resource_limit}\n"
"- **当前时间**{current_time}\n"
"- **项目结构**\n\n{file_tree}\n\n"
"{recent_uploads}"
"- **长期记忆**{memory}"
)
@ -288,6 +336,8 @@ class MessageMixin:
or self.project_path.name
)
recent_uploads = self._build_recent_uploads_block()
content = template.format(
project_name=project_name,
runtime_environment=runtime_environment,
@ -302,6 +352,7 @@ class MessageMixin:
if context["project_info"].get("file_tree")
else ""
),
recent_uploads=recent_uploads,
)
return content

View File

@ -335,7 +335,7 @@ class ProjectMixin:
root_label = f"{container_root} (项目根)"
else:
root_label = f"{container_root} (映射自 {project_name})"
lines.append(f"📁 {root_label}/")
lines.append(f"{root_label}/")
ROOT_FOLDER_CHILD_LIMIT = 20
@ -380,7 +380,7 @@ class ProjectMixin:
if info["type"] == "folder":
# 文件夹
lines.append(f"{prefix}{current_connector}📁 {name}/")
lines.append(f"{prefix}{current_connector}{name}/")
# 递归处理子项目
children = info.get("children") or {}
@ -396,11 +396,10 @@ class ProjectMixin:
build_tree_recursive(children, next_prefix, depth + 1)
else:
# 文件
icon = self._get_file_icon(name)
size_info = self._format_file_size(info['size'])
# 构建文件行
file_line = f"{prefix}{current_connector}{icon} {name}"
file_line = f"{prefix}{current_connector}{name}"
# 添加大小信息(简化版)
if info['size'] > 1024: # 只显示大于1KB的文件大小
@ -417,7 +416,7 @@ class ProjectMixin:
# 添加统计信息
lines.append("")
lines.append(f"📊 统计: {structure['total_files']} 个文件, {structure['total_size']/1024/1024:.2f}MB")
lines.append(f"统计: {structure['total_files']} 个文件, {structure['total_size']/1024/1024:.2f}MB")
return "\n".join(lines)
@ -431,68 +430,5 @@ class ProjectMixin:
return f"({size_bytes/1024/1024:.1f}MB)"
def _get_file_icon(self, filename: str) -> str:
"""根据文件类型返回合适的图标"""
ext = filename.split('.')[-1].lower() if '.' in filename else ''
icon_map = {
'py': '🐍', # Python
'js': '📜', # JavaScript
'ts': '📘', # TypeScript
'jsx': '⚛️', # React JSX
'tsx': '⚛️', # React TSX
'java': '', # Java
'cpp': '⚙️', # C++
'c': '⚙️', # C
'h': '📎', # Header files
'cs': '💷', # C#
'go': '🐹', # Go
'rs': '🦀', # Rust
'rb': '💎', # Ruby
'php': '🐘', # PHP
'swift': '🦉', # Swift
'kt': '🟣', # Kotlin
'md': '📝', # Markdown
'txt': '📄', # Text
'json': '📊', # JSON
'yaml': '📋', # YAML
'yml': '📋', # YAML
'toml': '📋', # TOML
'xml': '📰', # XML
'html': '🌐', # HTML
'css': '🎨', # CSS
'scss': '🎨', # SCSS
'less': '🎨', # LESS
'sql': '🗃️', # SQL
'db': '🗄️', # Database
'sh': '💻', # Shell script
'bash': '💻', # Bash script
'bat': '💻', # Batch file
'ps1': '💻', # PowerShell
'env': '🔧', # Environment
'gitignore': '🚫', # Gitignore
'dockerfile': '🐳', # Docker
'png': '🖼️', # Image
'jpg': '🖼️', # Image
'jpeg': '🖼️', # Image
'gif': '🖼️', # Image
'svg': '🖼️', # Image
'ico': '🖼️', # Icon
'mp4': '🎬', # Video
'mp3': '🎵', # Audio
'wav': '🎵', # Audio
'pdf': '📕', # PDF
'doc': '📘', # Word
'docx': '📘', # Word
'xls': '📗', # Excel
'xlsx': '📗', # Excel
'ppt': '📙', # PowerPoint
'pptx': '📙', # PowerPoint
'zip': '📦', # Archive
'rar': '📦', # Archive
'tar': '📦', # Archive
'gz': '📦', # Archive
'log': '📋', # Log file
'lock': '🔒', # Lock file
}
return icon_map.get(ext, '📄') # 默认文件图标
"""根据文件类型返回合适的图标(当前已禁用 emoji返回空字符串"""
return ""