From 31bbde9cddecef5d8f21060c4be3468dfe168e8a Mon Sep 17 00:00:00 2001 From: root Date: Sun, 13 Sep 2026 22:27:34 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ohos):=20Attachment=20463=E2=86=92344?= =?UTF-8?q?=20=E8=A1=8C=EF=BC=88=E7=BA=AF=E5=87=BD=E6=95=B0=E4=B8=8E?= =?UTF-8?q?=E5=AD=97=E8=8A=82=E8=A7=A3=E7=A0=81=E7=A7=BB=E5=87=BA=20compon?= =?UTF-8?q?ents=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common/AttachmentMeta.ets(106):parseAttachment / attachmentFromChannelOutput / fileNameOf / formatBytes / oneDecimal / extLabel / sanitize,纯函数无平台依赖 - common/AttachmentImage.ets(37):loadPixelMap(沙箱 file:// 与远端 /files 两条路径) components/Attachment.ets 只留 UI:AttachmentCard 与 AttachmentDetailContent。 **两者的导出名、@Prop 形状与 import 路径均未变**(ChatPage 仍从 '../components/Attachment' 取 AttachmentDetailContent),组件内部的布局、 过渡、提示文案与失败分支逐字保留,仅函数体搬走。引用方 5 处 import 路径同步更新。 验证:hvigorw assembleHap BUILD SUCCESSFUL;diff 中 Attachment.ets 只有 "-删除纯函数 + import 改写",无属性/布局改动。 --- .../src/main/ets/common/AttachmentImage.ets | 37 +++++ .../src/main/ets/common/AttachmentMeta.ets | 103 ++++++++++++++ .../entry/src/main/ets/common/ChatHistory.ets | 2 +- .../entry/src/main/ets/common/ChatSession.ets | 2 +- .../entry/src/main/ets/common/ChatSse.ets | 2 +- .../src/main/ets/components/Attachment.ets | 132 +----------------- .../src/main/ets/components/ChatAttachBar.ets | 2 +- .../src/main/ets/components/ChatComposer.ets | 2 +- 8 files changed, 151 insertions(+), 131 deletions(-) create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/AttachmentImage.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/AttachmentMeta.ets diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/AttachmentImage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/AttachmentImage.ets new file mode 100644 index 0000000..4bac3fc --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/AttachmentImage.ets @@ -0,0 +1,37 @@ +/** + * 附件的字节获取与解码(网络 / 沙箱 I/O)。 + * + * 字节走 GET /files/ 或 /uploads/(注意不带 /api/v1 前缀)。 + * 这两条路由在后端是 requireWeb,但对 API Key 客户端同等放行, + * 所以带上和普通接口一样的鉴权头即可,无需 web 登录态。 + * + * 从 components/Attachment.ets 抽出:缩略图与详情大图都走同一条解码路径。 + */ + +import { image } from '@kit.ImageKit'; +import { fileIo } from '@kit.CoreFileKit'; +import { apiClient } from './ApiClient'; + +/** 下载并解码成 PixelMap;任何一步失败都返回 undefined(调用方显示占位)。 */ +export async function loadPixelMap(url: string): Promise { + try { + // 本地待上传的图片:直接读沙箱文件,不走网络 + if (url.startsWith('file://')) { + const path: string = url.substring(7); + const f = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY); + const localSrc: image.ImageSource = image.createImageSource(f.fd); + const localPm: image.PixelMap = await localSrc.createPixelMap(); + await localSrc.release(); + fileIo.closeSync(f); + return localPm; + } + const abs: string = apiClient.absoluteUrl(url); + const resp = await apiClient.getBinary(abs, 15000); + const src: image.ImageSource = image.createImageSource(resp.data); + const pm: image.PixelMap = await src.createPixelMap(); + await src.release(); + return pm; + } catch (e) { + return undefined; + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/AttachmentMeta.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/AttachmentMeta.ets new file mode 100644 index 0000000..33f671a --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/AttachmentMeta.ets @@ -0,0 +1,103 @@ +/** + * 附件的解析与格式化(纯函数,无 UI、无平台 I/O)。 + * + * 后端 Attachment 只有四个字段:type / url / size / name + * (internal/plugins/webui/handler.go),没有 mime、没有像素尺寸、没有本地路径。 + * 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。 + * + * 从 components/Attachment.ets 抽出:附件卡与附件详情都要用这几个函数, + * 放在 common 里两边共用,也不必让 UI 文件承担这段纯逻辑。 + */ + +import { ChatAttachment } from '../model/Model'; + +/** 从后端 JSON 里解析 attachment 字段;缺字段或类型不对则返回 undefined。 */ +export function parseAttachment(raw: Object | undefined): ChatAttachment | undefined { + if (raw === undefined || raw === null) { + return undefined; + } + const o: Record = raw as Record; + const url: string = o['url'] as string ?? ''; + if (url.length === 0) { + return undefined; + } + const t: string = o['type'] as string ?? 'file'; + const a: ChatAttachment = { + type: t === 'image' ? 'image' : 'file', + url: url, + size: o['size'] as number ?? 0, + name: o['name'] as string ?? fileNameOf(url), + }; + return a; +} + +/** 由 SSE channel_output 事件构造附件(字段名与 history 不同)。 */ +export function attachmentFromChannelOutput( + outputType: string, url: string, size: number): ChatAttachment | undefined { + if (url.length === 0) { + return undefined; + } + if (outputType !== 'image' && outputType !== 'file') { + return undefined; + } + const a: ChatAttachment = { + type: outputType, + url: url, + size: size, + name: fileNameOf(url), + }; + return a; +} + +/** 取 URL 最后一段作为展示文件名,与后端 handler.go 的取名方式一致。 */ +export function fileNameOf(url: string): string { + let s: string = url; + const q: number = s.indexOf('?'); + if (q >= 0) { + s = s.substring(0, q); + } + const i: number = s.lastIndexOf('/'); + const name: string = i >= 0 ? s.substring(i + 1) : s; + return name.length > 0 ? name : '附件'; +} + +/** 人类可读字节数,口径对齐后端 formatBytes(KB 以上保留一位小数)。 */ +export function formatBytes(n: number): string { + if (n <= 0) { + return ''; + } + if (n < 1024) { + return n.toString() + ' B'; + } + const kb: number = n / 1024; + if (kb < 1024) { + return oneDecimal(kb) + ' KB'; + } + const mb: number = kb / 1024; + if (mb < 1024) { + return oneDecimal(mb) + ' MB'; + } + return oneDecimal(mb / 1024) + ' GB'; +} + +function oneDecimal(v: number): string { + return (Math.round(v * 10) / 10).toString(); +} + +/** 由文件名后缀猜测类型标签。后端不返回 mime,只能这样标注。 */ +export function extLabel(name: string): string { + const i: number = name.lastIndexOf('.'); + if (i < 0 || i === name.length - 1) { + return '未知类型'; + } + return name.substring(i + 1).toUpperCase(); +} + +/** 去掉路径分隔符,避免附件名把文件写到 filesDir 之外。 */ +export function sanitize(name: string): string { + let s: string = name.replace(/[\/\\:*?"<>|]/g, '_'); + if (s.length === 0) { + s = 'attachment'; + } + return s; +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatHistory.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatHistory.ets index 432d806..165ae1c 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatHistory.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatHistory.ets @@ -6,7 +6,7 @@ */ import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model'; -import { parseAttachment } from '../components/Attachment'; +import { parseAttachment } from './AttachmentMeta'; import { stringifyField } from './ChatFormat'; /** 分页历史解析结果:消息列表 + 服务端分页元数据 */ diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSession.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSession.ets index 02225ee..0fb5122 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSession.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSession.ets @@ -10,7 +10,7 @@ import { apiClient } from './ApiClient'; import { connStore } from './ConnStore'; import { userMessage, isTimeout } from './UserError'; import { chatStore } from './ChatStore'; -import { parseAttachment } from '../components/Attachment'; +import { parseAttachment } from './AttachmentMeta'; import { http } from '@kit.NetworkKit'; interface SendChatBody { diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets index cb34c39..a387ecb 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets @@ -7,7 +7,7 @@ */ import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model'; -import { attachmentFromChannelOutput } from '../components/Attachment'; +import { attachmentFromChannelOutput } from './AttachmentMeta'; import { SseEvent } from './SseClient'; import { stringifyField } from './ChatFormat'; import { hilog } from '@kit.PerformanceAnalysisKit'; diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/Attachment.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/Attachment.ets index 9ff7f6a..b5fb27b 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/components/Attachment.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/Attachment.ets @@ -4,107 +4,20 @@ import { common } from '@kit.AbilityKit'; import { apiClient } from '../common/ApiClient'; import { userMessage } from '../common/UserError'; import { ChatAttachment } from '../model/Model'; +import { extLabel, formatBytes, sanitize } from '../common/AttachmentMeta'; +import { loadPixelMap } from '../common/AttachmentImage'; import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD, RADIUS_SM, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; import { COLOR_ERROR } from '../common/Constants'; import { MotionBase } from './MotionBase'; import { PlainCard } from './SubPage'; /** - * 附件解析与展示。 + * 附件 UI:气泡内的附件卡 + 附件详情二级页内容。 * - * 后端 Attachment 只有四个字段:type / url / size / name - * (internal/plugins/webui/handler.go),没有 mime、没有像素尺寸、没有本地路径。 - * 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。 + * 解析/格式化(parseAttachment、formatBytes、extLabel…)在 common/AttachmentMeta.ets, + * 字节获取与解码在 common/AttachmentImage.ets —— 这里只留 UI。 * - * 字节走 GET /files/ 或 /uploads/(注意不带 /api/v1 前缀)。 - * 这两条路由在后端是 requireWeb,但对 API Key 客户端同等放行, - * 所以带上和普通接口一样的鉴权头即可,无需 web 登录态。 - */ - -/** 从后端 JSON 里解析 attachment 字段;缺字段或类型不对则返回 undefined。 */ -export function parseAttachment(raw: Object | undefined): ChatAttachment | undefined { - if (raw === undefined || raw === null) { - return undefined; - } - const o: Record = raw as Record; - const url: string = o['url'] as string ?? ''; - if (url.length === 0) { - return undefined; - } - const t: string = o['type'] as string ?? 'file'; - const a: ChatAttachment = { - type: t === 'image' ? 'image' : 'file', - url: url, - size: o['size'] as number ?? 0, - name: o['name'] as string ?? fileNameOf(url), - }; - return a; -} - -/** 由 SSE channel_output 事件构造附件(字段名与 history 不同)。 */ -export function attachmentFromChannelOutput( - outputType: string, url: string, size: number): ChatAttachment | undefined { - if (url.length === 0) { - return undefined; - } - if (outputType !== 'image' && outputType !== 'file') { - return undefined; - } - const a: ChatAttachment = { - type: outputType, - url: url, - size: size, - name: fileNameOf(url), - }; - return a; -} - -/** 取 URL 最后一段作为展示文件名,与后端 handler.go 的取名方式一致。 */ -export function fileNameOf(url: string): string { - let s: string = url; - const q: number = s.indexOf('?'); - if (q >= 0) { - s = s.substring(0, q); - } - const i: number = s.lastIndexOf('/'); - const name: string = i >= 0 ? s.substring(i + 1) : s; - return name.length > 0 ? name : '附件'; -} - -/** 人类可读字节数,口径对齐后端 formatBytes(KB 以上保留一位小数)。 */ -export function formatBytes(n: number): string { - if (n <= 0) { - return ''; - } - if (n < 1024) { - return n.toString() + ' B'; - } - const kb: number = n / 1024; - if (kb < 1024) { - return oneDecimal(kb) + ' KB'; - } - const mb: number = kb / 1024; - if (mb < 1024) { - return oneDecimal(mb) + ' MB'; - } - return oneDecimal(mb / 1024) + ' GB'; -} - -function oneDecimal(v: number): string { - return (Math.round(v * 10) / 10).toString(); -} - -/** 由文件名后缀猜测类型标签。后端不返回 mime,只能这样标注。 */ -export function extLabel(name: string): string { - const i: number = name.lastIndexOf('.'); - if (i < 0 || i === name.length - 1) { - return '未知类型'; - } - return name.substring(i + 1).toUpperCase(); -} - -/** - * 气泡内的附件卡:图片显示缩略图,文件显示一枚文件条。 + * 附件卡:图片显示缩略图,文件显示一枚文件条。 * 点击进入附件详情二级页面(WebGUI 是新开标签页,移动端改为二级页)。 */ @Component @@ -428,36 +341,3 @@ export struct AttachmentDetailContent { return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; } } - -/** 下载并解码成 PixelMap;任何一步失败都返回 undefined(调用方显示占位)。 */ -async function loadPixelMap(url: string): Promise { - try { - // 本地待上传的图片:直接读沙箱文件,不走网络 - if (url.startsWith('file://')) { - const path: string = url.substring(7); - const f = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY); - const localSrc: image.ImageSource = image.createImageSource(f.fd); - const localPm: image.PixelMap = await localSrc.createPixelMap(); - await localSrc.release(); - fileIo.closeSync(f); - return localPm; - } - const abs: string = apiClient.absoluteUrl(url); - const resp = await apiClient.getBinary(abs, 15000); - const src: image.ImageSource = image.createImageSource(resp.data); - const pm: image.PixelMap = await src.createPixelMap(); - await src.release(); - return pm; - } catch (e) { - return undefined; - } -} - -/** 去掉路径分隔符,避免附件名把文件写到 filesDir 之外。 */ -function sanitize(name: string): string { - let s: string = name.replace(/[\/\\:*?"<>|]/g, '_'); - if (s.length === 0) { - s = 'attachment'; - } - return s; -} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatAttachBar.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatAttachBar.ets index 635cd21..c361890 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatAttachBar.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatAttachBar.ets @@ -8,7 +8,7 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_PILL, ANIM_NORMAL } from '../common/Constants'; import { MotionBase } from './MotionBase'; -import { formatBytes } from './Attachment'; +import { formatBytes } from '../common/AttachmentMeta'; /** 加号菜单:两枚独立的玻璃胶囊,和输入区其他组件同一套视觉语言 */ @Component diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatComposer.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatComposer.ets index d8145b4..a0026e3 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatComposer.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatComposer.ets @@ -16,7 +16,7 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, ANIM_NORMAL } fro import { mimeOf } from '../common/ChatFormat'; import { chatStore, K_CHAT_LOADING } from '../common/ChatStore'; import { sendChatText, sendChatFile, interruptChat } from '../common/ChatSession'; -import { fileNameOf } from './Attachment'; +import { fileNameOf } from '../common/AttachmentMeta'; import { ChatAttachMenu, ChatPendingChip } from './ChatAttachBar'; import { NavFloatOverlay, FloatIconButton } from './PageTopBar'; import { picker, fileIo } from '@kit.CoreFileKit';