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';