feat: 上传文件/图片路径提示 + 文件树去 emoji + CSV 预览优化
This commit is contained in:
parent
d1a9a51bb8
commit
f3e166e258
@ -103,6 +103,7 @@
|
||||
- 用户说「给我这个文件」「下载 xxx」→ 用下载链接
|
||||
- 用户说「看一下这个文件」「展示 xxx 的内容」→ 用 `<show_file>` 卡片
|
||||
- 生成报告/数据文件后想展示成果 → 用 `<show_file>` 卡片
|
||||
- 用户让你处理、分析、修改文件,或任务可能产生结果文件时 → 完成后主动用 `<show_file>` 卡片或下载链接展示结果,不要等用户索要
|
||||
|
||||
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
||||
|
||||
|
||||
@ -103,6 +103,7 @@
|
||||
- 用户说「给我这个文件」「下载 xxx」→ 用下载链接
|
||||
- 用户说「看一下这个文件」「展示 xxx 的内容」→ 用 `<show_file>` 卡片
|
||||
- 生成报告/数据文件后想展示成果 → 用 `<show_file>` 卡片
|
||||
- 用户让你处理、分析、修改文件,或任务可能产生结果文件时 → 完成后主动用 `<show_file>` 卡片或下载链接展示结果,不要等用户索要
|
||||
|
||||
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
||||
|
||||
|
||||
@ -9,3 +9,5 @@
|
||||
```
|
||||
{file_tree}
|
||||
```
|
||||
|
||||
{recent_uploads}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user