feat: 上传文件/图片路径提示 + 文件树去 emoji + CSV 预览优化
This commit is contained in:
parent
d1a9a51bb8
commit
f3e166e258
@ -103,6 +103,7 @@
|
|||||||
- 用户说「给我这个文件」「下载 xxx」→ 用下载链接
|
- 用户说「给我这个文件」「下载 xxx」→ 用下载链接
|
||||||
- 用户说「看一下这个文件」「展示 xxx 的内容」→ 用 `<show_file>` 卡片
|
- 用户说「看一下这个文件」「展示 xxx 的内容」→ 用 `<show_file>` 卡片
|
||||||
- 生成报告/数据文件后想展示成果 → 用 `<show_file>` 卡片
|
- 生成报告/数据文件后想展示成果 → 用 `<show_file>` 卡片
|
||||||
|
- 用户让你处理、分析、修改文件,或任务可能产生结果文件时 → 完成后主动用 `<show_file>` 卡片或下载链接展示结果,不要等用户索要
|
||||||
|
|
||||||
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
||||||
|
|
||||||
|
|||||||
@ -103,6 +103,7 @@
|
|||||||
- 用户说「给我这个文件」「下载 xxx」→ 用下载链接
|
- 用户说「给我这个文件」「下载 xxx」→ 用下载链接
|
||||||
- 用户说「看一下这个文件」「展示 xxx 的内容」→ 用 `<show_file>` 卡片
|
- 用户说「看一下这个文件」「展示 xxx 的内容」→ 用 `<show_file>` 卡片
|
||||||
- 生成报告/数据文件后想展示成果 → 用 `<show_file>` 卡片
|
- 生成报告/数据文件后想展示成果 → 用 `<show_file>` 卡片
|
||||||
|
- 用户让你处理、分析、修改文件,或任务可能产生结果文件时 → 完成后主动用 `<show_file>` 卡片或下载链接展示结果,不要等用户索要
|
||||||
|
|
||||||
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
||||||
|
|
||||||
|
|||||||
@ -9,3 +9,5 @@
|
|||||||
```
|
```
|
||||||
{file_tree}
|
{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}
|
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(
|
def _should_skip_versioning_for_message(
|
||||||
*,
|
*,
|
||||||
message: str,
|
message: str,
|
||||||
@ -920,6 +945,19 @@ async def handle_task_with_sender(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if not auto_user_message_event:
|
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:
|
try:
|
||||||
sender(
|
sender(
|
||||||
'user_message',
|
'user_message',
|
||||||
|
|||||||
@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="sfc-loading" v-if="loading">
|
||||||
<span class="sfc-spinner"></span>
|
<span class="sfc-spinner"></span>
|
||||||
<span>加载中...</span>
|
<span>加载中...</span>
|
||||||
@ -35,7 +35,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="fileType === 'csv'">
|
<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">
|
<table class="sfc-csv-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -83,6 +83,24 @@ import Prism from 'prismjs';
|
|||||||
import { renderMarkdown } from '../../composables/useMarkdownRenderer';
|
import { renderMarkdown } from '../../composables/useMarkdownRenderer';
|
||||||
import PdfPreview from './PdfPreview.vue';
|
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' });
|
defineOptions({ name: 'ShowFileCard' });
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -206,8 +224,19 @@ function buildContentUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadContent() {
|
async function loadContent() {
|
||||||
loading.value = true;
|
if (!canPreview.value) return;
|
||||||
error.value = '';
|
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 {
|
try {
|
||||||
if (['image', 'pdf'].includes(fileType.value)) {
|
if (['image', 'pdf'].includes(fileType.value)) {
|
||||||
contentUrl.value = buildContentUrl();
|
contentUrl.value = buildContentUrl();
|
||||||
@ -219,7 +248,9 @@ async function loadContent() {
|
|||||||
error.value = msg || '加载失败';
|
error.value = msg || '加载失败';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rawContent.value = await resp.text();
|
const content = await resp.text();
|
||||||
|
rawContent.value = content;
|
||||||
|
setCachedShowFileContent(props.path, content);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = (e as Error).message || '网络错误';
|
error.value = (e as Error).message || '网络错误';
|
||||||
@ -362,6 +393,7 @@ onMounted(() => {
|
|||||||
.sfc-preview {
|
.sfc-preview {
|
||||||
border-top: 1px solid var(--border-default);
|
border-top: 1px solid var(--border-default);
|
||||||
max-height: 360px;
|
max-height: 360px;
|
||||||
|
min-height: 160px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--surface-base);
|
background: var(--surface-base);
|
||||||
@ -369,6 +401,12 @@ onMounted(() => {
|
|||||||
scrollbar-color: var(--text-muted) transparent;
|
scrollbar-color: var(--text-muted) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sfc-preview--csv {
|
||||||
|
overflow: auto;
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sfc-preview::-webkit-scrollbar {
|
.sfc-preview::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
@ -423,14 +461,24 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sfc-csv-scroll {
|
.sfc-csv-scroll {
|
||||||
overflow: auto;
|
overflow-x: auto;
|
||||||
max-height: 350px;
|
overflow-y: hidden;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.sfc-csv-scroll--loading {
|
||||||
|
min-height: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sfc-csv-table {
|
.sfc-csv-table {
|
||||||
width: 100%;
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sfc-csv-table th,
|
.sfc-csv-table th,
|
||||||
@ -444,8 +492,10 @@ onMounted(() => {
|
|||||||
.sfc-csv-table th {
|
.sfc-csv-table th {
|
||||||
background: var(--surface-soft);
|
background: var(--surface-soft);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
position: sticky;
|
}
|
||||||
top: 0;
|
|
||||||
|
.sfc-csv-table td {
|
||||||
|
background: var(--surface-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sfc-csv-meta {
|
.sfc-csv-meta {
|
||||||
|
|||||||
@ -74,9 +74,56 @@ AUTO_SHALLOW_TOOL_WHITELIST = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
MAX_RECENT_UPLOADS = 10
|
||||||
|
|
||||||
|
|
||||||
class MessageMixin:
|
class MessageMixin:
|
||||||
"""ContextManager message mixin 能力 mixin。"""
|
"""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(
|
def add_conversation(
|
||||||
self,
|
self,
|
||||||
role: str,
|
role: str,
|
||||||
@ -265,6 +312,7 @@ class MessageMixin:
|
|||||||
"- **资源限制**:{resource_limit}\n"
|
"- **资源限制**:{resource_limit}\n"
|
||||||
"- **当前时间**:{current_time}\n"
|
"- **当前时间**:{current_time}\n"
|
||||||
"- **项目结构**:\n\n{file_tree}\n\n"
|
"- **项目结构**:\n\n{file_tree}\n\n"
|
||||||
|
"{recent_uploads}"
|
||||||
"- **长期记忆**:{memory}"
|
"- **长期记忆**:{memory}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -288,6 +336,8 @@ class MessageMixin:
|
|||||||
or self.project_path.name
|
or self.project_path.name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recent_uploads = self._build_recent_uploads_block()
|
||||||
|
|
||||||
content = template.format(
|
content = template.format(
|
||||||
project_name=project_name,
|
project_name=project_name,
|
||||||
runtime_environment=runtime_environment,
|
runtime_environment=runtime_environment,
|
||||||
@ -302,6 +352,7 @@ class MessageMixin:
|
|||||||
if context["project_info"].get("file_tree")
|
if context["project_info"].get("file_tree")
|
||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
|
recent_uploads=recent_uploads,
|
||||||
)
|
)
|
||||||
|
|
||||||
return content
|
return content
|
||||||
|
|||||||
@ -335,7 +335,7 @@ class ProjectMixin:
|
|||||||
root_label = f"{container_root} (项目根)"
|
root_label = f"{container_root} (项目根)"
|
||||||
else:
|
else:
|
||||||
root_label = f"{container_root} (映射自 {project_name})"
|
root_label = f"{container_root} (映射自 {project_name})"
|
||||||
lines.append(f"📁 {root_label}/")
|
lines.append(f"{root_label}/")
|
||||||
|
|
||||||
ROOT_FOLDER_CHILD_LIMIT = 20
|
ROOT_FOLDER_CHILD_LIMIT = 20
|
||||||
|
|
||||||
@ -380,7 +380,7 @@ class ProjectMixin:
|
|||||||
|
|
||||||
if info["type"] == "folder":
|
if info["type"] == "folder":
|
||||||
# 文件夹
|
# 文件夹
|
||||||
lines.append(f"{prefix}{current_connector}📁 {name}/")
|
lines.append(f"{prefix}{current_connector}{name}/")
|
||||||
|
|
||||||
# 递归处理子项目
|
# 递归处理子项目
|
||||||
children = info.get("children") or {}
|
children = info.get("children") or {}
|
||||||
@ -396,11 +396,10 @@ class ProjectMixin:
|
|||||||
build_tree_recursive(children, next_prefix, depth + 1)
|
build_tree_recursive(children, next_prefix, depth + 1)
|
||||||
else:
|
else:
|
||||||
# 文件
|
# 文件
|
||||||
icon = self._get_file_icon(name)
|
|
||||||
size_info = self._format_file_size(info['size'])
|
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的文件大小
|
if info['size'] > 1024: # 只显示大于1KB的文件大小
|
||||||
@ -417,7 +416,7 @@ class ProjectMixin:
|
|||||||
|
|
||||||
# 添加统计信息
|
# 添加统计信息
|
||||||
lines.append("")
|
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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@ -431,68 +430,5 @@ class ProjectMixin:
|
|||||||
return f"({size_bytes/1024/1024:.1f}MB)"
|
return f"({size_bytes/1024/1024:.1f}MB)"
|
||||||
|
|
||||||
def _get_file_icon(self, filename: str) -> str:
|
def _get_file_icon(self, filename: str) -> str:
|
||||||
"""根据文件类型返回合适的图标"""
|
"""根据文件类型返回合适的图标(当前已禁用 emoji,返回空字符串)。"""
|
||||||
ext = filename.split('.')[-1].lower() if '.' in filename else ''
|
return ""
|
||||||
|
|
||||||
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, '📄') # 默认文件图标
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user