mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 15:53:56 +00:00
refactor(ohos): Attachment 463→344 行(纯函数与字节解码移出 components)
- 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 改写",无属性/布局改动。
This commit is contained in:
@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 附件的字节获取与解码(网络 / 沙箱 I/O)。
|
||||||
|
*
|
||||||
|
* 字节走 GET <base>/files/<name> 或 /uploads/<name>(注意不带 /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<image.PixelMap | undefined> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
cmd/ohos/HomeAgent/entry/src/main/ets/common/AttachmentMeta.ets
Normal file
103
cmd/ohos/HomeAgent/entry/src/main/ets/common/AttachmentMeta.ets
Normal file
@ -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<string, Object> = raw as Record<string, Object>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
|
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
|
||||||
import { parseAttachment } from '../components/Attachment';
|
import { parseAttachment } from './AttachmentMeta';
|
||||||
import { stringifyField } from './ChatFormat';
|
import { stringifyField } from './ChatFormat';
|
||||||
|
|
||||||
/** 分页历史解析结果:消息列表 + 服务端分页元数据 */
|
/** 分页历史解析结果:消息列表 + 服务端分页元数据 */
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { apiClient } from './ApiClient';
|
|||||||
import { connStore } from './ConnStore';
|
import { connStore } from './ConnStore';
|
||||||
import { userMessage, isTimeout } from './UserError';
|
import { userMessage, isTimeout } from './UserError';
|
||||||
import { chatStore } from './ChatStore';
|
import { chatStore } from './ChatStore';
|
||||||
import { parseAttachment } from '../components/Attachment';
|
import { parseAttachment } from './AttachmentMeta';
|
||||||
import { http } from '@kit.NetworkKit';
|
import { http } from '@kit.NetworkKit';
|
||||||
|
|
||||||
interface SendChatBody {
|
interface SendChatBody {
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
|
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
|
||||||
import { attachmentFromChannelOutput } from '../components/Attachment';
|
import { attachmentFromChannelOutput } from './AttachmentMeta';
|
||||||
import { SseEvent } from './SseClient';
|
import { SseEvent } from './SseClient';
|
||||||
import { stringifyField } from './ChatFormat';
|
import { stringifyField } from './ChatFormat';
|
||||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||||
|
|||||||
@ -4,107 +4,20 @@ import { common } from '@kit.AbilityKit';
|
|||||||
import { apiClient } from '../common/ApiClient';
|
import { apiClient } from '../common/ApiClient';
|
||||||
import { userMessage } from '../common/UserError';
|
import { userMessage } from '../common/UserError';
|
||||||
import { ChatAttachment } from '../model/Model';
|
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 { 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 { COLOR_ERROR } from '../common/Constants';
|
||||||
import { MotionBase } from './MotionBase';
|
import { MotionBase } from './MotionBase';
|
||||||
import { PlainCard } from './SubPage';
|
import { PlainCard } from './SubPage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 附件解析与展示。
|
* 附件 UI:气泡内的附件卡 + 附件详情二级页内容。
|
||||||
*
|
*
|
||||||
* 后端 Attachment 只有四个字段:type / url / size / name
|
* 解析/格式化(parseAttachment、formatBytes、extLabel…)在 common/AttachmentMeta.ets,
|
||||||
* (internal/plugins/webui/handler.go),没有 mime、没有像素尺寸、没有本地路径。
|
* 字节获取与解码在 common/AttachmentImage.ets —— 这里只留 UI。
|
||||||
* 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。
|
|
||||||
*
|
*
|
||||||
* 字节走 GET <base>/files/<name> 或 /uploads/<name>(注意不带 /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<string, Object> = raw as Record<string, Object>;
|
|
||||||
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 是新开标签页,移动端改为二级页)。
|
* 点击进入附件详情二级页面(WebGUI 是新开标签页,移动端改为二级页)。
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@ -428,36 +341,3 @@ export struct AttachmentDetailContent {
|
|||||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 下载并解码成 PixelMap;任何一步失败都返回 undefined(调用方显示占位)。 */
|
|
||||||
async function loadPixelMap(url: string): Promise<image.PixelMap | undefined> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_PILL,
|
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_PILL,
|
||||||
ANIM_NORMAL } from '../common/Constants';
|
ANIM_NORMAL } from '../common/Constants';
|
||||||
import { MotionBase } from './MotionBase';
|
import { MotionBase } from './MotionBase';
|
||||||
import { formatBytes } from './Attachment';
|
import { formatBytes } from '../common/AttachmentMeta';
|
||||||
|
|
||||||
/** 加号菜单:两枚独立的玻璃胶囊,和输入区其他组件同一套视觉语言 */
|
/** 加号菜单:两枚独立的玻璃胶囊,和输入区其他组件同一套视觉语言 */
|
||||||
@Component
|
@Component
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, ANIM_NORMAL } fro
|
|||||||
import { mimeOf } from '../common/ChatFormat';
|
import { mimeOf } from '../common/ChatFormat';
|
||||||
import { chatStore, K_CHAT_LOADING } from '../common/ChatStore';
|
import { chatStore, K_CHAT_LOADING } from '../common/ChatStore';
|
||||||
import { sendChatText, sendChatFile, interruptChat } from '../common/ChatSession';
|
import { sendChatText, sendChatFile, interruptChat } from '../common/ChatSession';
|
||||||
import { fileNameOf } from './Attachment';
|
import { fileNameOf } from '../common/AttachmentMeta';
|
||||||
import { ChatAttachMenu, ChatPendingChip } from './ChatAttachBar';
|
import { ChatAttachMenu, ChatPendingChip } from './ChatAttachBar';
|
||||||
import { NavFloatOverlay, FloatIconButton } from './PageTopBar';
|
import { NavFloatOverlay, FloatIconButton } from './PageTopBar';
|
||||||
import { picker, fileIo } from '@kit.CoreFileKit';
|
import { picker, fileIo } from '@kit.CoreFileKit';
|
||||||
|
|||||||
Reference in New Issue
Block a user