Просмотр исходного кода

修复聊天文件来源并排除执行过程文件展示

LSamien 1 месяц назад
Родитель
Сommit
44cab5c569

+ 17 - 0
docs/verification/2026-09-09-message-files/README.md

@@ -0,0 +1,17 @@
+# 聊天文件来源验收
+
+2026-09-09。Web、桌面壳及共享服务端只从聊天正文与明确发送的附件建立文件关联。停止扫描工具事件、工具参数、结果、预览和思考。列表、统计、消息附件查询及历史/实时展示共用规则。历史自动归档关联在读取时按正文核对,原始记录及文件内容保留。用户上传和明确发布到消息的附件继续可用。
+
+## 验证
+
+- `npm run typecheck`、`npm run build` 通过。
+- 文件提取/展示、工作区接口、消息缓存等 8 个测试文件共 85 项通过。
+- 另执行 messageFiles、workspace、workspaceRemoteAgents、workspacePairedNodes 共 71 项运行时测试通过(其中 messageFiles 的 2 项与上述重叠)。
+- Playwright:`npx playwright test -c playwright.workspace.config.ts -g 'only chat messages introduce files'`,1 项通过。
+- 隔离 fixture 实测:过程区只有文本、没有图片及文件卡;文件库只返回报告;正文报告可预览,刷新后仍然正确。
+- 旧关联回归证明:工具文件仍在磁盘,工具 JSON 未变;正文后来引用同一文件后可重新显示;跨用户隔离保持有效。
+- 日志:`/tmp/yaoyao-message-files-web-{typecheck,build,tests,runtime-tests,ui}.log`。
+
+本次没有部署或发布;界面证明使用隔离测试服务,不代表线上服务已更新。
+
+![Web 展开过程与聊天文件](web.png)

BIN
docs/verification/2026-09-09-message-files/web.png


+ 11 - 4
src/client/components/messages/MarkdownContent.vue

@@ -15,8 +15,9 @@ const props = withDefaults(defineProps<{
   plain?: boolean
   mentionNames?: string[]
   fileCards?: boolean
+  processContent?: boolean
   outlinePrefix?: string
-}>(), { streaming: false, legacyMedia: false, plain: false, fileCards: false, outlinePrefix: '' })
+}>(), { streaming: false, legacyMedia: false, plain: false, fileCards: false, processContent: false, outlinePrefix: '' })
 
 const emit = defineEmits<{ fileLink: [name: string, url: string]; rendered: [] }>()
 
@@ -88,13 +89,19 @@ const md = new MarkdownIt({
 // Dashboard 规范:保留原文引号,不做智能引号替换。
 md.disable('smartquotes')
 
+const defaultImage = md.renderer.rules.image!
+md.renderer.rules.image = (tokens, index, options, env, self) => {
+  if (props.processContent) return escapeHtml(tokens[index].content || tokens[index].attrGet('src') || '图片')
+  return defaultImage(tokens, index, options, env, self)
+}
+
 const defaultLinkOpen = md.renderer.rules.link_open ?? ((tokens, index, options, _env, self) => self.renderToken(tokens, index, options))
 md.renderer.rules.link_open = (tokens, index, options, env, self) => {
   const token = tokens[index]
   // Some assistants wrap real Hermes workspace paths in a sandbox: URI.
   // Resolve only this known file route before sanitizing the Markdown.
   const href = token.attrGet('href') || ''
-  if (props.fileCards && /^sandbox:\/Users\/[^/]+\/\.hermes\/(?:profiles\/[^/]+\/)?workspace\/.+/i.test(href)) {
+  if (!props.processContent && props.fileCards && /^sandbox:\/Users\/[^/]+\/\.hermes\/(?:profiles\/[^/]+\/)?workspace\/.+/i.test(href)) {
     token.attrSet('href', href.slice('sandbox:'.length))
   }
   token.attrSet('target', '_blank')
@@ -176,7 +183,7 @@ function decorateCopyButtons() {
 }
 
 function decorateFileLinks() {
-  if (!props.fileCards || !root.value) return
+  if (props.processContent || !props.fileCards || !root.value) return
   root.value.querySelectorAll<HTMLAnchorElement>('a').forEach(link => {
     if (link.dataset.fileCard) return
     let url: URL
@@ -199,7 +206,7 @@ function decorateFileLinks() {
 }
 
 function decorateMediaPreviews() {
-  if (!props.fileCards || !root.value) return
+  if (props.processContent || !props.fileCards || !root.value) return
   root.value.querySelectorAll<HTMLImageElement>('img').forEach(image => {
     if (image.dataset.mediaPreview) return
     let url: URL

+ 8 - 7
src/client/components/messages/MessageTimeline.vue

@@ -284,25 +284,25 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
             <template v-if="message.timelineKind === 'delegation-complete'">
               <details class="delegation-event">
                 <summary><AppIcon name="groups" :size="14" /><span><strong>子任务已完成</strong><small>{{ delegationSummary(message.timelineMetadata) }}</small></span></summary>
-                <MarkdownContent v-if="message.content" :content="message.content" />
+                <MarkdownContent process-content v-if="message.content" :content="message.content" />
               </details>
             </template>
             <template v-else-if="message.timelineKind === 'background-process'">
               <details class="system-event background-process-event">
                 <summary><AppIcon name="tools" :size="13" /><span>{{ backgroundProcessSummary(message.timelineMetadata) }}</span></summary>
-                <MarkdownContent v-if="message.content" :content="message.content" />
+                <MarkdownContent process-content v-if="message.content" :content="message.content" />
               </details>
             </template>
             <template v-else-if="message.timelineKind === 'system' && isCompactionEvent(message)">
               <details class="delegation-event compaction-event">
                 <summary><AppIcon name="archive" :size="14" /><span><strong>上下文已压缩</strong><small>压缩摘要已归档 · 点击查看</small></span></summary>
-                <MarkdownContent v-if="message.content" :content="message.content" />
+                <MarkdownContent process-content v-if="message.content" :content="message.content" />
               </details>
             </template>
             <template v-else-if="message.timelineKind === 'system'">
               <details class="system-event">
                 <summary><AppIcon name="settings" :size="13" /><span>{{ systemSummary(message.content) }}</span></summary>
-                <MarkdownContent v-if="message.content" :content="message.content" />
+                <MarkdownContent process-content v-if="message.content" :content="message.content" />
               </details>
             </template>
             <template v-else>
@@ -315,7 +315,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 
             <details v-if="message.reasoning" class="message__reasoning">
               <summary><AppIcon name="brain" :size="13" />思考过程 · {{ message.reasoning.length }} 字</summary>
-              <MarkdownContent :content="message.reasoning" :streaming="message.status === 'streaming'" @rendered="onMarkdownRendered" />
+              <MarkdownContent process-content :content="message.reasoning" :streaming="message.status === 'streaming'" @rendered="onMarkdownRendered" />
             </details>
 
             <div v-if="message.role === 'user' && message.attachments?.length" class="message__attachments">
@@ -334,7 +334,8 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
                 :plain="message.role === 'user'"
                 :mention-names="mentionNames"
                 :outline-prefix="message.role === 'assistant' ? `outline-${message.id}` : ''"
-                file-cards
+                :file-cards="message.role === 'user' || message.role === 'assistant'"
+                :process-content="message.role !== 'user' && message.role !== 'assistant'"
                 @rendered="onMarkdownRendered"
                 @file-link="(name, url) => emit('previewFile', { name, url })"
               />
@@ -351,7 +352,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               {{ deliveryLabel(message.status) }}
             </div>
 
-            <div v-if="message.role !== 'user' && message.attachments?.length" class="message__attachments">
+            <div v-if="message.role === 'assistant' && message.attachments?.length" class="message__attachments">
               <button v-for="attachment in message.attachments" :key="attachment.id" type="button" @click="emit('preview', attachment)">
                 <img v-if="attachment.kind === 'image' && attachment.url" :src="attachment.url" :alt="attachment.name" />
                 <span v-else><AppIcon :name="attachment.kind || 'file'" :size="17" /></span>

+ 1 - 1
src/client/components/messages/TurnTrace.vue

@@ -28,7 +28,7 @@ const statusLabel = computed(() => props.group.status === 'running' ? '进行中
       <template v-for="entry in group.entries" :key="entry.id">
         <section v-if="entry.type === 'reasoning'" class="turn-trace__item turn-trace__reasoning">
           <header><AppIcon name="brain" :size="12" />思考过程 · {{ entry.content.length }} 字</header>
-          <MarkdownContent :content="entry.content" :streaming="group.status === 'running'" />
+          <MarkdownContent process-content :content="entry.content" :streaming="group.status === 'running'" />
         </section>
         <ToolTrace v-else class="turn-trace__item turn-trace__tool" :tool="entry.tool" expanded />
       </template>

+ 6 - 5
src/client/components/workspace/viewModels.ts

@@ -1,3 +1,4 @@
+import { visibleMessageText, messageReasoningText } from '@shared/messageFiles'
 import type {
   ApprovalRequest,
   ChatMessage,
@@ -122,8 +123,8 @@ export function chatMessageToUi(message: ChatMessage, agentNameFor?: (profile?:
     id: message.id,
     role: message.role,
     author: message.role === 'assistant' ? agentNameFor?.(message.profile) || message.profile : undefined,
-    content: message.content,
-    reasoning: message.reasoning,
+    content: message.role === 'assistant' ? visibleMessageText(message.content) : message.content,
+    reasoning: message.role === 'assistant' ? messageReasoningText(message.content, message.reasoning) : message.reasoning,
     createdAt: message.timestamp < 10_000_000_000 ? message.timestamp * 1000 : message.timestamp,
     status: message.isStreaming ? 'streaming' : message.stage,
     error: message.error,
@@ -258,7 +259,7 @@ export function groupMessageToUi(message: GroupMessage, agents: GroupAgent[] = [
         : `node:${sender.nodeId}:${sender.profile}`
       : undefined,
     content,
-    reasoning: message.reasoning,
+    reasoning: message.senderKind === 'agent' ? messageReasoningText(message.content, message.reasoning) : message.reasoning,
     createdAt: message.createdAt < 10_000_000_000 ? message.createdAt * 1000 : message.createdAt,
     status: message.status === 'completed' ? 'settled' : message.status === 'queued' ? 'pending' : message.status === 'streaming' ? 'streaming' : message.status === 'failed' ? 'failed' : message.status === 'unknown' ? 'unknown-receipt' : 'settled',
     error: message.error,
@@ -369,11 +370,11 @@ export function workspaceMessagesToUi(messages: import('@shared/workspace').Work
   return messages.filter(message => message.visible !== false).map(message => ({
     id: message.id, role: message.role, author: message.agentName,
     profile: message.agentId, createdAt: message.createdAt,
-    content: message.content.replace(/(!?\[[^\]]*\])\(<?([^)>]+)>?\)/g, (whole, label: string, path: string) => {
+    content: (message.role === 'assistant' ? visibleMessageText(message.content) : message.content).replace(/(!?\[[^\]]*\])\(<?([^)>]+)>?\)/g, (whole, label: string, path: string) => {
       const file = message.attachments.find(file => file.sourcePath === path)
       return file ? `${label}(/api/app/files/${file.id}/${label.startsWith('!') ? 'preview' : 'download'})` : whole
     }),
-    reasoning: message.reasoning,
+    reasoning: message.role === 'assistant' ? messageReasoningText(message.content, message.reasoning) : message.reasoning,
     status: message.status === 'complete' || message.status === 'interrupted' ? 'settled'
       : message.status === 'uncertain' ? 'unknown-receipt'
       : message.status === 'queued' ? 'pending' : message.status,

+ 1 - 1
src/client/stores/artifacts.ts

@@ -179,7 +179,7 @@ export const useArtifactsStore = defineStore('artifacts', () => {
         } catch {
           failedSessionCount.value += 1
           loadedSessionCount.value += 1
-          return cached?.artifacts ?? []
+          return cached?.extractorVersion === ARTIFACT_EXTRACTOR_VERSION ? cached.artifacts : []
         }
       })
       artifacts.value = enrichPreview(batches.flat(), files).sort((a, b) => b.timestamp - a.timestamp)

+ 7 - 28
src/client/utils/artifacts.ts

@@ -1,6 +1,7 @@
-import type { ChatMessage, ConversationArtifact, JsonValue, SessionSummary } from '@shared/types'
+import { visibleMessageText } from '@shared/messageFiles'
+import type { ChatMessage, ConversationArtifact, SessionSummary } from '@shared/types'
 
-export const ARTIFACT_EXTRACTOR_VERSION = 2
+export const ARTIFACT_EXTRACTOR_VERSION = 3
 
 const markdownImage = /!\[([^\]]*)\]\(([^)\s]+)\)/gi
 const markdownLink = /(?<!!)\[([^\]]+)\]\(([^)\s]+)\)/gi
@@ -8,7 +9,7 @@ const rawUrl = /https?:\/\/[^\s<>"')]+/gi
 const localPath = /(?:^|[\s(:"'`])((?:\/|~\/|\.\.?\/)[^\s"'`<>]+(?:\.[a-z0-9]{1,8})?)/gim
 const imageExtensions = new Set(['avif', 'bmp', 'gif', 'heic', 'heif', 'jpeg', 'jpg', 'png', 'svg', 'tif', 'tiff', 'webp'])
 const fileExtensions = new Set([...imageExtensions, 'pdf', 'txt', 'json', 'md', 'csv', 'zip', 'tar', 'gz', 'mp3', 'wav', '3gp', 'avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'webm', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'])
-const keyHints = ['path', 'file', 'url', 'image', 'artifact', 'output', 'download', 'result', 'target']
+
 
 interface Candidate { value: string; label?: string; mimeType?: string; attachment?: ConversationArtifact['attachment'] }
 
@@ -58,42 +59,20 @@ function textCandidates(text: string): Candidate[] {
   return result
 }
 
-function collectStringValues(value: JsonValue, keyPath: string, output: Candidate[]): void {
-  if (typeof value === 'string') {
-    const normalized = normalize(value)
-    if ((keyHints.some(hint => keyPath.toLowerCase().includes(hint)) || looksLikePathOrUrl(normalized)) && looksLikeArtifact(normalized)) {
-      output.push({ value: normalized })
-    }
-    return
-  }
-  if (Array.isArray(value)) return value.forEach((child, index) => collectStringValues(child, `${keyPath}.${index}`, output))
-  if (value && typeof value === 'object') {
-    for (const key of Object.keys(value).sort()) collectStringValues(value[key], `${keyPath}.${key}`, output)
-  }
-}
-
 function candidates(message: ChatMessage): Candidate[] {
-  const result = textCandidates(message.content)
+  const result = textCandidates(visibleMessageText(message.content))
   for (const attachment of message.attachments ?? []) {
     const value = attachment.path || attachment.url
     if (value) result.push({ value, mimeType: attachment.mimeType, attachment })
   }
-  for (const call of message.toolCalls ?? []) {
-    if (call.arguments !== undefined) collectStringValues(call.arguments, 'tool.arguments', result)
-    if (call.result !== undefined) collectStringValues(call.result, 'tool.result', result)
-    if (call.preview) result.push(...textCandidates(call.preview))
-  }
-  if (message.role === 'tool') {
-    try { collectStringValues(JSON.parse(message.content) as JsonValue, 'tool.result', result) } catch { /* plain tool text is already scanned */ }
-  }
   return result
 }
 
-/** Extracts assistant/tool artifacts and intentionally ignores user rows. */
+/** Extracts files delivered in assistant messages, never execution details. */
 export function extractArtifacts(session: SessionSummary, messages: ChatMessage[]): ConversationArtifact[] {
   const found = new Map<string, ConversationArtifact>()
   for (const message of messages) {
-    if (message.role !== 'assistant' && message.role !== 'tool') continue
+    if (message.role !== 'assistant' || Boolean(message.displayKind)) continue
     for (const candidate of candidates(message)) {
       const value = normalize(candidate.value)
       if (!value || !looksLikeArtifact(value)) continue

+ 3 - 0
src/client/utils/messageDisplay.ts

@@ -1,3 +1,5 @@
+import { visibleMessageText } from '@shared/messageFiles'
+
 const ATTACHED_CONTEXT_MARKER_RE = /(?:^|\n)--- Attached Context ---\s*\n/
 const CONTEXT_WARNINGS_MARKER_RE = /(?:^|\n)--- Context Warnings ---[\s\S]*$/
 const CONTEXT_REF_RE = /@(file|folder|url|image|tool|terminal):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)/g
@@ -11,6 +13,7 @@ const CONTEXT_REF_RE = /@(file|folder|url|image|tool|terminal):(?:"[^"\n]+"|'[^'
  * apply this projection only at display/copy boundaries.
  */
 export function displayContentForMessage(role: string, content: string): string {
+  if (role === 'assistant') return visibleMessageText(content)
   if (role !== 'user') return content
 
   const marker = content.match(ATTACHED_CONTEXT_MARKER_RE)

+ 13 - 2
src/server/app.ts

@@ -1,3 +1,4 @@
+import { nativeMessageFileText } from '../shared/messageFiles.js'
 import { hermesBotRelay } from './hermesBotRelay.js'
 import { remoteAgentExports } from './workspaceRemoteAgents.js'
 import { randomBytes, createHash } from 'node:crypto'
@@ -240,11 +241,21 @@ export function createApplication(options: ApplicationOptions = {}): Application
   realtime.broker.onNativeEvent = (owner, profile, storedId, frame) => {
     chatCache?.observe(owner, profile, storedId, frame)
     if (owner.startsWith('device:')) return
-    if (!['message.complete', 'tool.complete', 'tool.completed', 'attachment.staged'].includes(frame.type)) return
-    const data = frame.payload ?? {}, text = JSON.stringify(data)
+    if (!['message.complete', 'attachment.staged'].includes(frame.type)) return
+    const data = frame.payload ?? {}, text = frame.type === 'attachment.staged' ? JSON.stringify(data) : nativeMessageFileText(data)
     const messageId = String(data.row_id ?? data.message_id ?? data.id ?? createHash('sha256').update(text).digest('hex'))
     void workspaceAssets.archiveText(owner, text, 'local', profile, storedId, messageId, frame.type === 'attachment.staged' ? 'user' : 'agent').catch(() => {})
   }
+  workspace.nativeMessageForFile = (owner, file) => {
+    const message = chatCache?.store.fileSourceMessage(owner, file.profile ?? 'default', file.conversationId ?? '', file.messageId ?? '')
+    if (!message) return undefined
+    return {
+      id: file.messageId!, conversationId: file.conversationId!, seq: 0,
+      role: message.role === 'assistant' ? 'assistant' : message.role === 'user' ? 'user' : 'system',
+      content: nativeMessageFileText(message), reasoning: '', status: 'complete',
+      attachments: [], tools: [], createdAt: file.createdAt,
+    }
+  }
   realtime.broker.onNativeGlobalEvent = (owner, type) => chatCache?.observeGlobal(owner, type)
   chatCache.onSynchronized = (owner, profile, sessionID) => {
     realtime.broker.publishOwnerSessionsChanged(owner, 'cache.synced', profile, sessionID)

+ 6 - 0
src/server/chatCache.ts

@@ -501,6 +501,12 @@ export class ChatCacheStore {
       .run(owner, sessionID)
   }
 
+  fileSourceMessage(owner: string, profile: string, sessionID: string, messageID: string): Record<string, unknown> | undefined {
+    const row = this.db.prepare('SELECT data FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND message_id=?')
+      .get(owner, profile, sessionID, messageID)
+    return row ? JSON.parse(String(row.data)) : undefined
+  }
+
   markListsStale(owner: string): void {
     this.db.prepare("UPDATE chat_snapshots SET sync_state='stale' WHERE owner=? AND kind='list'").run(owner)
   }

+ 16 - 12
src/server/workspaceAssets.ts

@@ -3,16 +3,17 @@ import { mkdirSync, writeFileSync } from 'node:fs'
 import { join, basename } from 'node:path'
 import {HttpError} from './errors.js'
 import { lookup } from 'mime-types'
+import { messageFileReferences, type MessageFileOrigin } from '../shared/messageFiles.js'
 import { WorkspaceStore } from './workspaceStore.js'
 import { WorkspaceNodes } from './workspaceGateway.js'
 import type { WorkspaceAgent, WorkspaceFile, WorkspaceMessage } from '../shared/workspace.js'
-export interface StoredWorkspaceFile extends WorkspaceFile {
+export interface StoredWorkspaceFile extends WorkspaceFile, MessageFileOrigin {
   path: string
   digest?: string
   sourceNodeId?:string
 }
 export function publicFile(f: StoredWorkspaceFile): WorkspaceFile {
-  const { path: _path, digest: _digest, ...result } = f
+  const { path: _path, digest: _digest, messageFileSource: _source, ...result } = f
   return result
 }
 export function libraryFile(
@@ -94,16 +95,16 @@ export class WorkspaceAssets {
     const dir=join(this.home,'workspace-files',owner);mkdirSync(dir,{recursive:true,mode:0o700})
     const path=join(dir,digest)
     try{writeFileSync(path,bytes,{mode:0o600,flag:'wx'})}catch(error){if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error}
-    const file:StoredWorkspaceFile={id:randomUUID(),name,mimeType:lookup(name)||'application/octet-stream',path,digest,size:bytes.length,sender:'agent',profile:agent.profile,sourceNodeId:agent.nodeId,conversationId:message.conversationId,messageId:message.id,createdAt:Date.now()}
+    const file:StoredWorkspaceFile={id:randomUUID(),name,mimeType:lookup(name)||'application/octet-stream',path,digest,size:bytes.length,sender:'agent',messageFileSource:'attachment',profile:agent.profile,sourceNodeId:agent.nodeId,conversationId:message.conversationId,messageId:message.id,createdAt:Date.now()}
     this.store.atomic(()=>{this.store.put(owner,'file',file.id,file);this.store.event(owner,'files.changed',publicFile(file),message.conversationId)})
     return publicFile(file)
   }
   async archive(owner: string, message: WorkspaceMessage): Promise<void> {
-    if (!message.agentId) return
+    if (!message.agentId || message.role !== 'assistant' || message.visible === false) return
     const agent = this.store.require<WorkspaceAgent>(owner, 'agent', message.agentId)
     await this.archiveText(
       owner,
-      message.content + '\n' + JSON.stringify(message.tools),
+      message.content,
       agent.nodeId,
       agent.profile,
       message.conversationId,
@@ -113,10 +114,10 @@ export class WorkspaceAssets {
     )
     if (this.stopped) return
     const attachments = this.store
-      .list<StoredWorkspaceFile>(owner, 'file')
+      .visibleFiles(owner)
       .filter((f) => f.messageId === message.id)
       .map(publicFile)
-    if (attachments.length) {
+    if (attachments.length || message.attachments.length) {
       message.attachments = attachments
       this.store.saveMessage(owner, message)
     }
@@ -134,11 +135,13 @@ export class WorkspaceAssets {
     if (this.stopped) return
     const message=this.store.get<WorkspaceMessage>(owner,'message',messageId)
     if(message?.execution==='computer'||(!message?.execution&&message?.agentId&&this.store.get<WorkspaceAgent>(owner,'agent',message.agentId)?.execution==='computer'))return
-    const paths = new Set<string>()
-    for (const m of text.matchAll(
-      /(?:MEDIA:\s*|\]\(<?|"(?:path|file_path|output_path)"\s*:\s*")((?:\/|~\/)[^\n"<>)]{1,4096})/g,
-    ))
-      paths.add(m[1]!.replace(/\\n.*$/, '').trim())
+    const paths = new Set([...messageFileReferences(text)].filter(path => /^(?:\/|~\/)/.test(path)))
+    // User attachment staging is explicit upload metadata, not a tool result.
+    if (sender === 'user') {
+      for (const match of text.matchAll(/"(?:path|file_path|output_path)"\s*:\s*"((?:\/|~\/)[^"\n<>]+)"/g)) {
+        paths.add(match[1]!)
+      }
+    }
     for (const path of [...paths].slice(0, 8)) {
       const key = createHash('sha256')
         .update(JSON.stringify([nodeId, profile, messageId, path]))
@@ -164,6 +167,7 @@ export class WorkspaceAssets {
         const file: StoredWorkspaceFile = {
           id,
           sourcePath: path,
+          messageFileSource: 'reference',
           path: saved,
           digest,
           name,

+ 3 - 3
src/server/workspaceRoutes.ts

@@ -351,7 +351,7 @@ export function workspaceRouter(
   })
   router.get('/api/app/files', (ctx) => {
     const files = store
-      .list<StoredWorkspaceFile>(owner(ctx), 'file')
+      .visibleFiles(owner(ctx))
       .filter(
         (f) =>
           (!ctx.query.search ||
@@ -372,7 +372,7 @@ export function workspaceRouter(
     }
   })
   router.get('/api/app/files/stats', (ctx) => {
-    const files = store.list<StoredWorkspaceFile>(owner(ctx), 'file')
+    const files = store.visibleFiles(owner(ctx))
     ctx.body = { count: files.length, totalBytes: files.reduce((sum, f) => sum + f.size, 0) }
   })
   router.post('/api/app/message-files/query', (ctx) => {
@@ -380,7 +380,7 @@ export function workspaceRouter(
       z.object({ messageIds: z.array(z.union([z.string(), z.number()])).max(500) }),
       body(ctx),
     ).messageIds.map(String)
-    const files = store.list<StoredWorkspaceFile>(owner(ctx), 'file')
+    const files = store.visibleFiles(owner(ctx))
     ctx.body = {
       messages: Object.fromEntries(
         ids.map((id) => [

+ 22 - 3
src/server/workspaceStore.ts

@@ -5,6 +5,8 @@ import { DatabaseSync } from 'node:sqlite'
 import { EventEmitter } from 'node:events'
 import { z } from 'zod'
 import { HttpError } from './errors.js'
+import { isVisibleMessageFile } from '../shared/messageFiles.js'
+import type { StoredWorkspaceFile } from './workspaceAssets.js'
 import { notificationPlainText } from './notificationText.js'
 import { decodeAgentMascotAvatar, isAgentImageAvatar, defaultAgentIdentity, encodeAgentAvatar, normalizeAvatar, randomAgentIdentity, MAX_AVATAR_DESCRIPTOR_LENGTH } from '../shared/agentIdentity.js'
 import type {
@@ -235,7 +237,7 @@ export class WorkspaceStore {
         .prepare('INSERT INTO workspace_events(owner,type,conversation_id,data) VALUES(?,?,?,?)')
         .run(owner, type, conversationId ?? null, JSON.stringify(data)).lastInsertRowid,
     )
-    const event = { seq, type, conversationId, data }
+    const event = { seq, type, conversationId, data: this.eventDataForDisplay(owner, type, data) }
     if (this.transactionEvents) this.transactionEvents.push({ owner, event })
     else this.publish(owner, event)
     return seq
@@ -248,7 +250,7 @@ export class WorkspaceStore {
         seq: Number(r.seq),
         type: String(r.type),
         conversationId: r.conversation_id ? String(r.conversation_id) : undefined,
-        data: JSON.parse(String(r.data)),
+        data: this.eventDataForDisplay(owner, String(r.type), JSON.parse(String(r.data))),
       }))
   }
   cursor(owner: string): number {
@@ -725,9 +727,26 @@ export class WorkspaceStore {
         `SELECT data FROM workspace_entities WHERE owner=? AND kind='message' AND json_extract(data,'$.conversationId')=? ${taskClause} AND json_extract(data,'$.seq')<? ${includeHidden ? '' : "AND coalesce(json_extract(data,'$.visible'),1) != 0"} ORDER BY json_extract(data,'$.seq') DESC LIMIT ?`,
       )
       .all(...(conversationTaskId ? [owner, conversationId, conversationTaskId, before, limit] : [owner, conversationId, before, limit]))
-      .map((r) => JSON.parse(String(r.data)) as Message)
+      .map((r) => this.messageForDisplay(owner, JSON.parse(String(r.data)) as Message))
       .reverse()
   }
+  nativeMessageForFile?: (owner: string, file: StoredWorkspaceFile) => Message | undefined
+  visibleFiles(owner: string): StoredWorkspaceFile[] {
+    const messages = new Map(this.list<Message>(owner, 'message').map(message => [message.id, message]))
+    return this.list<StoredWorkspaceFile>(owner, 'file').filter(file =>
+      isVisibleMessageFile(file, messages.get(file.messageId ?? '') ?? this.nativeMessageForFile?.(owner, file)))
+  }
+  messageForDisplay(owner: string, message: Message): Message {
+    const attachments = message.role === 'user' || message.role === 'assistant'
+      ? message.attachments.filter(file => isVisibleMessageFile(
+        this.get<StoredWorkspaceFile>(owner, 'file', file.id) ?? file, message))
+      : []
+    return { ...message, attachments }
+  }
+  private eventDataForDisplay(owner: string, type: string, data: unknown): unknown {
+    if (type === 'message.changed') return this.messageForDisplay(owner, data as Message)
+    return data
+  }
   saveMessage(owner: string, message: Message): void {
     this.atomic(() => {
       const c = this.require<Conversation>(owner, 'conversation', message.conversationId)

+ 89 - 0
src/shared/messageFiles.ts

@@ -0,0 +1,89 @@
+import MarkdownIt from 'markdown-it'
+import type Token from 'markdown-it/lib/token.mjs'
+import type { WorkspaceFile, WorkspaceMessage } from './workspace.js'
+
+/** Only the conversational body can introduce a visible file reference. */
+export function visibleMessageText(text: string): string {
+  return text
+    .replace(/<(think|thinking|reasoning)>[\s\S]*?<\/\1>/gi, '')
+    .replace(/<(?:think|thinking|reasoning)>[\s\S]*$/gi, '')
+}
+
+export function messageReasoningText(text: string, reasoning = ''): string {
+  const inline = [...text.matchAll(/<(think|thinking|reasoning)>([\s\S]*?)(?:<\/\1>|$)/gi)]
+    .map(match => match[2]!.trim()).filter(Boolean)
+  return [...new Set([reasoning.trim(), ...inline].filter(Boolean))].join('\n\n')
+}
+
+const markdown = new MarkdownIt({ html: false })
+
+export function normalizedMessagePath(value: string): string {
+  const path = value.replace(/^sandbox:/i, '')
+  try { return decodeURIComponent(path) } catch { return path }
+}
+
+export function messageFileReferences(text: string): Set<string> {
+  const paths = new Set<string>()
+  const visit = (tokens: Token[]) => {
+    for (const token of tokens) {
+      const destination = token.type === 'link_open' ? token.attrGet('href')
+        : token.type === 'image' ? token.attrGet('src') : undefined
+      if (destination) paths.add(normalizedMessagePath(destination))
+      if (token.type === 'text') {
+        for (const match of token.content.matchAll(/\bMEDIA:\s*(?:`([^`\n]+)`|"([^"\n]+)"|'([^'\n]+)'|([^\s]+))/gi)) {
+          paths.add(normalizedMessagePath(match[1] ?? match[2] ?? match[3] ?? match[4]!))
+        }
+      }
+      if (token.children) visit(token.children)
+    }
+  }
+  visit(markdown.parse(visibleMessageText(text), {}))
+  return paths
+}
+
+export interface MessageFileOrigin {
+  /** Absent on legacy records; references are reconciled against the body. */
+  messageFileSource?: 'attachment' | 'reference'
+}
+
+export function isVisibleMessageFile(
+  file: WorkspaceFile & MessageFileOrigin,
+  message?: WorkspaceMessage,
+): boolean {
+  // Explicit user uploads also belong in the library before a draft is sent.
+  if (file.sender === 'user') return true
+  if (message && (message.visible === false || message.role !== 'assistant')) return false
+  if (file.messageFileSource === 'attachment') return true
+  if (!message) return file.messageFileSource === 'reference'
+  // Older explicitly published attachments have no sourcePath; the archive
+  // scanner always recorded one. Do not trust an old attachment array alone.
+  if (!file.sourcePath) return message.attachments.some(item => item.id === file.id)
+  const references = messageFileReferences(message.content)
+  return references.has(normalizedMessagePath(file.sourcePath))
+    || references.has(`/api/app/files/${file.id}/download`)
+    || references.has(`/api/app/files/${file.id}/preview`)
+}
+
+/** Extract only the declared body/attachments of a native message payload. */
+export function nativeMessageFileText(data: Record<string, unknown>): string {
+  if (data.role && data.role !== 'assistant' && data.role !== 'user') return ''
+  if (data.display_kind || data.displayKind) return ''
+  const content = data.content ?? data.text ?? data.output ?? ''
+  const blocks = Array.isArray(content) ? content : []
+  const texts = typeof content === 'string' ? [content] : []
+  const attachments = [
+    ...blocks,
+    ...(Array.isArray(data.attachments) ? data.attachments : []),
+    ...(Array.isArray(data.files) ? data.files : []),
+  ]
+  for (const value of attachments) {
+    if (!value || typeof value !== 'object') continue
+    const block = value as Record<string, unknown>
+    if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text)
+    if (block.type && !['image', 'image_url', 'file', 'audio', 'video'].includes(String(block.type))) continue
+    const image = block.image_url as { url?: string } | undefined
+    const path = block.path ?? block.url ?? image?.url
+    if (typeof path === 'string' && !/[<>\r\n]/.test(path)) texts.push(`[附件](<${path}>)`)
+  }
+  return visibleMessageText(texts.join('\n'))
+}

+ 12 - 3
tests/client/artifactStore.test.ts

@@ -30,12 +30,21 @@ describe('artifact extractor', () => {
     expect(artifacts[0].attachment?.id).toBe('attachment-1')
   })
 
-  it('extracts hinted tool result paths and deduplicates within a session', () => {
+  it('excludes files mentioned only in tool results', () => {
     const artifacts = extractArtifacts(session, [row('tool', 'tool', '{"output_path":"/tmp/result.pdf"}', {
       toolCalls: [{ id: 'tool-1', name: 'write', status: 'completed', result: { output_path: '/tmp/result.pdf' } }],
     })])
-    expect(artifacts).toHaveLength(1)
-    expect(artifacts[0]).toMatchObject({ kind: 'file', value: '/tmp/result.pdf', label: 'result.pdf' })
+    expect(artifacts).toEqual([])
+  })
+
+  it('collects the delivered body while ignoring reasoning and attached tool details', () => {
+    const artifacts = extractArtifacts(session, [row('assistant', 'assistant',
+      '<think>[过程](/tmp/private.pdf)</think>[交付](/tmp/result.pdf)', {
+        reasoning: '![过程图片](/tmp/process.png)',
+        toolCalls: [{ id: 'tool', name: 'read', status: 'completed', arguments: { path: '/tmp/input.pdf' }, result: { path: '/tmp/result.pdf' }, preview: '/tmp/preview.pdf' }],
+      })])
+    expect(artifacts.map(item => item.value)).toEqual(['/tmp/result.pdf'])
+    expect(artifacts[0].messageId).toBe('assistant')
   })
 
   it('keeps the same artifact path when it was produced by distinct messages', () => {

+ 15 - 0
tests/client/markdownContent.test.ts

@@ -165,3 +165,18 @@ describe('streaming Markdown', () => {
     wrapper.unmount()
   })
 })
+
+it('renders process media as text while retaining message media previews', async () => {
+  const content = '![过程图](/tmp/process.png) [过程文件](/Users/test/Agents/process.pdf)'
+  const process = mount(MarkdownContent, { props: { content, processContent: true, fileCards: true } })
+  await nextTick()
+  expect(process.find('img').exists()).toBe(false)
+  expect(process.find('.file-link-card').exists()).toBe(false)
+  expect(process.text()).toContain('过程图')
+  const message = mount(MarkdownContent, { props: { content, fileCards: true } })
+  await nextTick()
+  expect(message.find('img').exists()).toBe(true)
+  expect(message.find('.file-link-card').exists()).toBe(true)
+  process.unmount()
+  message.unmount()
+})

+ 11 - 0
tests/client/workspacePresentation.test.ts

@@ -46,3 +46,14 @@ it('composes real member avatars and refreshes them without changing group membe
   expect(wrapper.find('.sidebar-item .agent-avatar--waiting').exists()).toBe(true)
   wrapper.unmount()
 })
+
+
+it('keeps inline thinking in the process trace and its media out of the message body', () => {
+  const messages = workspaceMessagesToUi([{
+    id: 'inline-thinking', conversationId: 'chat', seq: 1, role: 'assistant',
+    content: '<think>![过程图](/tmp/process.png)</think>[报告](/tmp/report.pdf)',
+    reasoning: '', status: 'complete', attachments: [], tools: [], createdAt: 1,
+  }])
+  expect(messages[0].content).toBe('[报告](/tmp/report.pdf)')
+  expect(messages[0].reasoning).toBe('![过程图](/tmp/process.png)')
+})

+ 26 - 0
tests/e2e/workspace-chat.spec.ts

@@ -519,3 +519,29 @@ for (const mode of ['普通聊天', 'Bot 单聊', '群聊', '普通长回复'] a
     }
   })
 }
+
+
+test('only chat messages introduce files across transcript and library', async ({ page }, testInfo) => {
+  await login(page)
+  await createAgent(page, '文件展示验收')
+  await page.getByPlaceholder('输入消息,Enter 发送,Shift + Enter 换行').fill('[message-files] 请发送报告')
+  await page.getByRole('button', { name: '发送消息', exact: true }).click()
+  const report = page.locator('.message--assistant .file-link-card').first()
+  await expect(report).toBeVisible()
+  const process = page.locator('.turn-trace')
+  await expect(process).toHaveCount(1)
+  await process.locator('summary').first().click()
+  await expect(process).toContainText('/tmp/process.pdf')
+  await expect(process.locator('img, .file-link-card')).toHaveCount(0)
+  const files = await (await page.request.get('/api/app/files')).json()
+  expect(files.items.map((file: { name: string }) => file.name)).toEqual(['workspace-report.txt'])
+  await report.click()
+  const preview = page.getByRole('dialog', { name: '预览 workspace-report.txt', exact: true })
+  await expect(preview).toContainText('Fixture report')
+  await preview.getByRole('button', { name: '关闭预览' }).click()
+  await page.reload()
+  await expect(page.locator('.message--assistant .file-link-card')).toHaveCount(1)
+  await page.locator('.turn-trace summary').first().click()
+  await page.setViewportSize({ width: 1280, height: 1100 })
+  await page.screenshot({ path: testInfo.outputPath('message-files-web.png') })
+})

+ 5 - 0
tests/fixtures/workspace-server.ts

@@ -261,6 +261,11 @@ wss.on('connection', (socket) => {
       const markdownFinal = `${markdownPrefix}\n\x60\x60\x60\n\n| 名称 | 数量 |\n| --- | --- |\n| 项目 | 2 |\n\n最终完整标记`
       const text = markdownStream ? markdownFinal : `我是${name}。已按角色规则处理这条消息。\n\n- 会话独立保存\n- 可以继续交流\n\n[报告](/tmp/workspace-report.txt)${delegates ? `\n请${requested}处理任务。` : ''}`
       event('message.start', {}, f.params.session_id)
+      if (String(f.params.text).includes('[message-files]')) {
+        event('reasoning.delta', { text: '![过程图](/tmp/process.png)' }, f.params.session_id)
+        event('tool.start', { tool_id: 'process-file', name: 'read_file', arguments: { path: '/tmp/input.pdf' } }, f.params.session_id)
+        event('tool.complete', { tool_id: 'process-file', name: 'read_file', result: { path: '/tmp/process.pdf' } }, f.params.session_id)
+      }
       if (crossClientRun) event('tool.start', { tool_id: 'cross-client-tool', name: 'read_file' }, f.params.session_id)
       else setTimeout(() => event('message.delta', { text: markdownStream ? markdownPrefix : text.slice(0, 12) }, f.params.session_id), 80)
       const complete = () => {

+ 61 - 0
tests/server/messageFiles.test.ts

@@ -0,0 +1,61 @@
+// @vitest-environment node
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { expect, it, vi } from 'vitest'
+import { WorkspaceStore } from '../../src/server/workspaceStore'
+import { WorkspaceAssets, type StoredWorkspaceFile } from '../../src/server/workspaceAssets'
+import type { WorkspaceNodes } from '../../src/server/workspaceGateway'
+import type { WorkspaceMessage, WorkspaceConversation } from '../../src/shared/workspace'
+import { messageFileReferences, nativeMessageFileText } from '../../src/shared/messageFiles'
+
+it('archives only message references and reconciles legacy associations without deleting bytes or logs', async () => {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-message-files-'))
+  const store = new WorkspaceStore(home)
+  const request = vi.fn(async (_route: string, _options: { search: URLSearchParams }) => ({ status: 200, body: Buffer.from('report'), headers: new Headers({ 'content-type': 'text/plain' }) }))
+  const assets = new WorkspaceAssets(store, { target: () => ({ session: { request } }) } as unknown as WorkspaceNodes, home)
+  try {
+    const agent = store.createAgent('owner', { name: 'Agent', profile: 'default' })
+    const conversation = store.list<WorkspaceConversation>('owner', 'conversation')[0]!
+    const message: WorkspaceMessage = {
+      id: 'reply', conversationId: conversation.id, agentId: agent.id, seq: 0, role: 'assistant',
+      content: '<think>[hidden](/tmp/thinking.pdf)</think>[报告](sandbox:/tmp/report.txt)',
+      reasoning: '[思考](/tmp/reasoning.pdf)', tools: [{ arguments: { path: '/tmp/input.pdf' }, result: { path: '/tmp/process.pdf' } }],
+      status: 'complete', attachments: [], createdAt: Date.now(),
+    }
+    store.saveMessage('owner', message)
+    await assets.archive('owner', message)
+    expect(request).toHaveBeenCalledTimes(1)
+    expect(request.mock.calls[0]?.[1]?.search.get('path')).toBe('/tmp/report.txt')
+    // Reproduce an old archive which mixed a tool path into the message.
+    await assets.archiveText('owner', '[old](/tmp/process.pdf)', 'local', 'default', conversation.id, message.id)
+    const all = store.list<StoredWorkspaceFile>('owner', 'file')
+    for (const file of all) {
+      delete file.messageFileSource
+      store.put('owner', 'file', file.id, file)
+    }
+    message.attachments = all
+    store.saveMessage('owner', message)
+    expect(store.messages('owner', conversation.id)[0]?.attachments.map(file => file.name)).toEqual(['report.txt'])
+    expect(store.visibleFiles('owner').map(file => file.name)).toEqual(['report.txt'])
+    const replay = store.events('owner', 0).filter(event => event.type === 'message.changed').at(-1)!
+    expect((replay.data as WorkspaceMessage).attachments.map(file => file.name)).toEqual(['report.txt'])
+    expect(store.get<WorkspaceMessage>('owner', 'message', message.id)?.tools).toEqual(message.tools)
+    expect(all.every(file => readFileSync(file.path, 'utf8') === 'report')).toBe(true)
+    message.content += '\n[交付过程文件](/tmp/process.pdf)'
+    store.saveMessage('owner', message)
+    expect(store.visibleFiles('owner')).toHaveLength(2)
+    expect(store.visibleFiles('another-owner')).toEqual([])
+  } finally { assets.close(); store.close(); rmSync(home, { recursive: true, force: true }) }
+})
+
+it('does not scan native reasoning, tool data, code examples or unfinished thinking', () => {
+  const text = nativeMessageFileText({
+    text: '[交付](/tmp/report.pdf)', reasoning: '/tmp/reasoning.pdf',
+    tools: [{ result: { path: '/tmp/process.pdf' } }],
+    attachments: [{ path: '/tmp/attached.pdf', name: 'attached.pdf' }],
+  })
+  expect([...messageFileReferences(text)]).toEqual(['/tmp/report.pdf', '/tmp/attached.pdf'])
+  expect(nativeMessageFileText({ role: 'tool', content: '[过程](/tmp/process.pdf)' })).toBe('')
+  expect([...messageFileReferences('```json\n{"path":"/tmp/process.pdf"}\n```\n<think>![图](/tmp/hidden.png)')]).toEqual([])
+})