mirror of
https://gitcode.com/JianFeeeee/TrulyMEM-TrueHumanMEM.git
synced 2026-10-15 12:15:32 +00:00
feat: 纯血鸿蒙 ArkTS 工程(harmonyos 分支)
- 新增 harmonyos/ 目录:完整 ArkTS 项目 - GraphDatabase.ets:@ohos.data.relationalStore 图数据库 - Index.ets:Tabs 导航(星图|聊天|设置) - GraphPage.ets:WebView 星图(Three.js 3D) - ChatPage.ets:聊天 + HTTP AI API 调用 - SettingsPage.ets:@ohos.data.preferences 配置 - graph.html:Three.js 力导向星图可视化 - 删除:ui/ TUI 代码、core/migrate.py 多用户迁移、trulymem_entry.py - 配置:build-profile.json5, module.json5, permissions
This commit is contained in:
10
harmonyos/AppScope/app.json5
Normal file
10
harmonyos/AppScope/app.json5
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"bundleName": "com.trulymem.app",
|
||||||
|
"vendor": "trulymem",
|
||||||
|
"versionCode": 1000000,
|
||||||
|
"versionName": "1.0.0",
|
||||||
|
"icon": ":layered_image",
|
||||||
|
"label": ":app_name"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
harmonyos/build-profile.json5
Normal file
19
harmonyos/build-profile.json5
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"signingConfigs": [],
|
||||||
|
"products": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"signingConfig": "default",
|
||||||
|
"compatibleSdkVersion": "6.0.0.48",
|
||||||
|
"compileSdkVersion": "6.0.0.48"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"name": "entry",
|
||||||
|
"srcPath": "./entry"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
10
harmonyos/entry/build-profile.json5
Normal file
10
harmonyos/entry/build-profile.json5
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"apiType": "stageMode",
|
||||||
|
"buildOption": {},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"runtimeOS": "HarmonyOS"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
44
harmonyos/entry/src/main/ets/common/Constants.ets
Normal file
44
harmonyos/entry/src/main/ets/common/Constants.ets
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
export class Constants {
|
||||||
|
static readonly DB_NAME: string = 'trulymem.db';
|
||||||
|
static readonly CONFIG_PREF_NAME: string = 'trulymem_config';
|
||||||
|
static readonly DEFAULT_BASE_URL: string = 'https://api.deepseek.com';
|
||||||
|
static readonly DEFAULT_MODEL: string = 'deepseek-chat';
|
||||||
|
static readonly SECURITY_LEVEL: number = 1; // S1
|
||||||
|
|
||||||
|
// Table names
|
||||||
|
static readonly TABLE_NODES: string = 'nodes';
|
||||||
|
static readonly TABLE_RELATIONS: string = 'relations';
|
||||||
|
static readonly TABLE_CHAT: string = 'chat_records';
|
||||||
|
|
||||||
|
// SQL definitions
|
||||||
|
static readonly SQL_CREATE_NODES: string = `
|
||||||
|
CREATE TABLE IF NOT EXISTS nodes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
type TEXT DEFAULT 'concept',
|
||||||
|
mentions INTEGER DEFAULT 1,
|
||||||
|
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now','localtime'))
|
||||||
|
)`;
|
||||||
|
|
||||||
|
static readonly SQL_CREATE_RELATIONS: string = `
|
||||||
|
CREATE TABLE IF NOT EXISTS relations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
subject_id INTEGER NOT NULL,
|
||||||
|
relation TEXT NOT NULL,
|
||||||
|
object_id INTEGER NOT NULL,
|
||||||
|
weight REAL DEFAULT 1.0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||||
|
FOREIGN KEY (subject_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (object_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||||
|
)`;
|
||||||
|
|
||||||
|
static readonly SQL_CREATE_CHAT: string = `
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
tools TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now','localtime'))
|
||||||
|
)`;
|
||||||
|
}
|
||||||
36
harmonyos/entry/src/main/ets/entryability/EntryAbility.ets
Normal file
36
harmonyos/entry/src/main/ets/entryability/EntryAbility.ets
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import AbilityConstant from '@ohos.app.ability.AbilityConstant';
|
||||||
|
import UIAbility from '@ohos.app.ability.UIAbility';
|
||||||
|
import Window from '@ohos.app.ability.Window';
|
||||||
|
import Want from '@ohos.app.ability.Want';
|
||||||
|
|
||||||
|
export default class EntryAbility extends UIAbility {
|
||||||
|
onCreate(want: Want, param: AbilityConstant.LaunchParam): void {
|
||||||
|
console.info('EntryAbility onCreate');
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(): void {
|
||||||
|
console.info('EntryAbility onDestroy');
|
||||||
|
}
|
||||||
|
|
||||||
|
onWindowStageCreate(windowStage: Window.WindowStage): void {
|
||||||
|
windowStage.loadContent('pages/Index', (err, data) => {
|
||||||
|
if (err.code) {
|
||||||
|
console.error('Failed to load content: ' + JSON.stringify(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.info('Succeeded in loading content: ' + JSON.stringify(data));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onWindowStageDestroy(): void {
|
||||||
|
console.info('EntryAbility onWindowStageDestroy');
|
||||||
|
}
|
||||||
|
|
||||||
|
onForeground(): void {
|
||||||
|
console.info('EntryAbility onForeground');
|
||||||
|
}
|
||||||
|
|
||||||
|
onBackground(): void {
|
||||||
|
console.info('EntryAbility onBackground');
|
||||||
|
}
|
||||||
|
}
|
||||||
448
harmonyos/entry/src/main/ets/model/GraphDatabase.ets
Normal file
448
harmonyos/entry/src/main/ets/model/GraphDatabase.ets
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
import relationalStore from '@ohos.data.relationalStore';
|
||||||
|
import { Constants } from '../common/Constants';
|
||||||
|
|
||||||
|
export interface TripletInput {
|
||||||
|
subject: string;
|
||||||
|
relation: string;
|
||||||
|
object: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphNode {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
mentions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEdge {
|
||||||
|
id: number;
|
||||||
|
source: number;
|
||||||
|
target: number;
|
||||||
|
label: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphData {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
edges: GraphEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecallResult {
|
||||||
|
success: boolean;
|
||||||
|
data: GraphData;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurgeCriteria {
|
||||||
|
subject?: string;
|
||||||
|
target?: string;
|
||||||
|
relation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurgeResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
affected?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
archived: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleanupResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
deleted: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: number;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
tools?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GraphDatabase {
|
||||||
|
private store: relationalStore.RdbStore | null = null;
|
||||||
|
|
||||||
|
async init(context: any): Promise<void> {
|
||||||
|
const STORE_CONFIG: relationalStore.StoreConfig = {
|
||||||
|
name: Constants.DB_NAME,
|
||||||
|
securityLevel: Constants.SECURITY_LEVEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.store = await relationalStore.getRdbStore(context, STORE_CONFIG);
|
||||||
|
await this.store.executeSql(Constants.SQL_CREATE_NODES);
|
||||||
|
await this.store.executeSql(Constants.SQL_CREATE_RELATIONS);
|
||||||
|
await this.store.executeSql(Constants.SQL_CREATE_CHAT);
|
||||||
|
console.info('GraphDatabase initialized successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertStore(): relationalStore.RdbStore {
|
||||||
|
if (!this.store) {
|
||||||
|
throw new Error('GraphDatabase not initialized. Call init() first.');
|
||||||
|
}
|
||||||
|
return this.store;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Memory Operations ============
|
||||||
|
|
||||||
|
async commit(triplets: TripletInput[]): Promise<CommitResult> {
|
||||||
|
const store = this.assertStore();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.beginTransaction();
|
||||||
|
|
||||||
|
for (const t of triplets) {
|
||||||
|
// Upsert subject node
|
||||||
|
const subjId = await this.upsertNode(store, t.subject);
|
||||||
|
|
||||||
|
// Upsert object node
|
||||||
|
const objId = await this.upsertNode(store, t.object);
|
||||||
|
|
||||||
|
// Create relation
|
||||||
|
const bucket: relationalStore.ValuesBucket = {
|
||||||
|
'subject_id': subjId,
|
||||||
|
'relation': t.relation,
|
||||||
|
'object_id': objId,
|
||||||
|
'weight': 1.0,
|
||||||
|
};
|
||||||
|
await store.insert(Constants.TABLE_RELATIONS, bucket);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await store.commit();
|
||||||
|
return { success: true, message: `Committed ${count} triplets`, count };
|
||||||
|
} catch (e) {
|
||||||
|
await store.rollBack();
|
||||||
|
return { success: false, message: `Commit failed: ${e}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertNode(store: relationalStore.RdbStore, name: string): Promise<number> {
|
||||||
|
// Check if node exists
|
||||||
|
const predicates = new relationalStore.RdbPredicates(Constants.TABLE_NODES);
|
||||||
|
predicates.equalTo('name', name);
|
||||||
|
const resultSet = await store.query(predicates, ['id', 'mentions']);
|
||||||
|
|
||||||
|
let nodeId: number;
|
||||||
|
if (resultSet.rowCount > 0) {
|
||||||
|
resultSet.goToFirstRow();
|
||||||
|
nodeId = resultSet.getLong(resultSet.getColumnIndex('id'));
|
||||||
|
const mentions = resultSet.getLong(resultSet.getColumnIndex('mentions'));
|
||||||
|
resultSet.close();
|
||||||
|
|
||||||
|
// Update mentions + updated_at
|
||||||
|
const updateBucket: relationalStore.ValuesBucket = {
|
||||||
|
'mentions': mentions + 1,
|
||||||
|
'updated_at': this.getNow(),
|
||||||
|
};
|
||||||
|
const updatePredicates = new relationalStore.RdbPredicates(Constants.TABLE_NODES);
|
||||||
|
updatePredicates.equalTo('id', nodeId);
|
||||||
|
await store.update(updateBucket, updatePredicates);
|
||||||
|
} else {
|
||||||
|
resultSet.close();
|
||||||
|
const insertBucket: relationalStore.ValuesBucket = {
|
||||||
|
'name': name,
|
||||||
|
'type': 'concept',
|
||||||
|
'mentions': 1,
|
||||||
|
'created_at': this.getNow(),
|
||||||
|
'updated_at': this.getNow(),
|
||||||
|
};
|
||||||
|
nodeId = await store.insert(Constants.TABLE_NODES, insertBucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async recall(queryIntent: string, seedEntities?: string[]): Promise<RecallResult> {
|
||||||
|
const store = this.assertStore();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (seedEntities && seedEntities.length > 0) {
|
||||||
|
// Filter by seed entities
|
||||||
|
const placeholders = seedEntities.map(() => '?').join(',');
|
||||||
|
const nodeSql = `SELECT * FROM nodes WHERE name IN (${placeholders}) ORDER BY mentions DESC`;
|
||||||
|
const nodeResultSet = await store.querySql(nodeSql, seedEntities);
|
||||||
|
|
||||||
|
const nodeIds: number[] = [];
|
||||||
|
const nodes: GraphNode[] = [];
|
||||||
|
while (nodeResultSet.goToNextRow()) {
|
||||||
|
const node = this.rowToNode(nodeResultSet);
|
||||||
|
nodes.push(node);
|
||||||
|
nodeIds.push(node.id);
|
||||||
|
}
|
||||||
|
nodeResultSet.close();
|
||||||
|
|
||||||
|
if (nodeIds.length === 0) {
|
||||||
|
return { success: true, data: { nodes: [], edges: [] }, message: 'No results found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get related edges
|
||||||
|
const edgeIdPlaceholders = nodeIds.map(() => '?').join(',');
|
||||||
|
const edgeParams = nodeIds.map(id => id.toString());
|
||||||
|
const edgeSql = `SELECT * FROM relations WHERE subject_id IN (${edgeIdPlaceholders}) OR object_id IN (${edgeIdPlaceholders})`;
|
||||||
|
const edgeResultSet = await store.querySql(edgeSql, edgeParams);
|
||||||
|
|
||||||
|
const edges: GraphEdge[] = [];
|
||||||
|
while (edgeResultSet.goToNextRow()) {
|
||||||
|
edges.push(this.rowToEdge(edgeResultSet));
|
||||||
|
}
|
||||||
|
edgeResultSet.close();
|
||||||
|
|
||||||
|
// Collect all connected node IDs
|
||||||
|
const allNodeIds = new Set<number>(nodeIds);
|
||||||
|
for (const edge of edges) {
|
||||||
|
allNodeIds.add(edge.source);
|
||||||
|
allNodeIds.add(edge.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all connected nodes
|
||||||
|
if (allNodeIds.size > nodeIds.length) {
|
||||||
|
const allIdPlaceholders = Array.from(allNodeIds).map(() => '?').join(',');
|
||||||
|
const allIdParams = Array.from(allNodeIds).map(id => id.toString());
|
||||||
|
const allNodeSql = `SELECT * FROM nodes WHERE id IN (${allIdPlaceholders})`;
|
||||||
|
const allNodeRs = await store.querySql(allNodeSql, allIdParams);
|
||||||
|
|
||||||
|
const allNodes: GraphNode[] = [];
|
||||||
|
while (allNodeRs.goToNextRow()) {
|
||||||
|
allNodes.push(this.rowToNode(allNodeRs));
|
||||||
|
}
|
||||||
|
allNodeRs.close();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { nodes: allNodes, edges },
|
||||||
|
message: `Found ${allNodes.length} nodes, ${edges.length} edges`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { nodes, edges },
|
||||||
|
message: `Found ${nodes.length} nodes, ${edges.length} edges`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return all data
|
||||||
|
return await this.introspect();
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, data: { nodes: [], edges: [] }, message: `Recall failed: ${e}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async purge(criteria: PurgeCriteria): Promise<PurgeResult> {
|
||||||
|
const store = this.assertStore();
|
||||||
|
let affected = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (criteria.target) {
|
||||||
|
// Delete by target node name
|
||||||
|
const nodePredicates = new relationalStore.RdbPredicates(Constants.TABLE_NODES);
|
||||||
|
nodePredicates.equalTo('name', criteria.target);
|
||||||
|
const rs = await store.query(nodePredicates, ['id']);
|
||||||
|
while (rs.goToNextRow()) {
|
||||||
|
const nodeId = rs.getLong(rs.getColumnIndex('id'));
|
||||||
|
// Delete relations involving this node
|
||||||
|
const relPredicates1 = new relationalStore.RdbPredicates(Constants.TABLE_RELATIONS);
|
||||||
|
relPredicates1.equalTo('subject_id', nodeId);
|
||||||
|
await store.delete(relPredicates1);
|
||||||
|
|
||||||
|
const relPredicates2 = new relationalStore.RdbPredicates(Constants.TABLE_RELATIONS);
|
||||||
|
relPredicates2.equalTo('object_id', nodeId);
|
||||||
|
await store.delete(relPredicates2);
|
||||||
|
|
||||||
|
// Delete the node itself
|
||||||
|
const delPredicates = new relationalStore.RdbPredicates(Constants.TABLE_NODES);
|
||||||
|
delPredicates.equalTo('id', nodeId);
|
||||||
|
affected += await store.delete(delPredicates);
|
||||||
|
}
|
||||||
|
rs.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.subject) {
|
||||||
|
// More sophisticated deletion could be added here
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Purged ${affected} nodes`,
|
||||||
|
affected,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, message: `Purge failed: ${e}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async introspect(): Promise<RecallResult> {
|
||||||
|
const store = this.assertStore();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all nodes
|
||||||
|
const nodeRs = await store.querySql('SELECT * FROM nodes ORDER BY mentions DESC LIMIT 500');
|
||||||
|
const nodes: GraphNode[] = [];
|
||||||
|
while (nodeRs.goToNextRow()) {
|
||||||
|
nodes.push(this.rowToNode(nodeRs));
|
||||||
|
}
|
||||||
|
nodeRs.close();
|
||||||
|
|
||||||
|
// Get all edges
|
||||||
|
const edgeRs = await store.querySql('SELECT * FROM relations ORDER BY weight DESC LIMIT 1000');
|
||||||
|
const edges: GraphEdge[] = [];
|
||||||
|
while (edgeRs.goToNextRow()) {
|
||||||
|
edges.push(this.rowToEdge(edgeRs));
|
||||||
|
}
|
||||||
|
edgeRs.close();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { nodes, edges },
|
||||||
|
message: `Found ${nodes.length} nodes, ${edges.length} edges`,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, data: { nodes: [], edges: [] }, message: `Introspect failed: ${e}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async archive(days: number): Promise<ArchiveResult> {
|
||||||
|
const store = this.assertStore();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sql = `DELETE FROM relations WHERE created_at < datetime('now', '-${days} days')`;
|
||||||
|
const changes = await store.executeSql(sql);
|
||||||
|
return { success: true, message: `Archived relations older than ${days} days`, archived: changes };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, message: `Archive failed: ${e}`, archived: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanup(dryRun: boolean): Promise<CleanupResult> {
|
||||||
|
const store = this.assertStore();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (dryRun) {
|
||||||
|
const rs = await store.querySql(`
|
||||||
|
SELECT COUNT(*) as cnt FROM nodes n
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM relations WHERE subject_id = n.id OR object_id = n.id)
|
||||||
|
AND n.name NOT IN ('_ROOT_', '_UNKNOWN_')
|
||||||
|
`);
|
||||||
|
rs.goToFirstRow();
|
||||||
|
const count = rs.getLong(rs.getColumnIndex('cnt'));
|
||||||
|
rs.close();
|
||||||
|
return { success: true, message: `Dry run: ${count} orphaned nodes would be deleted`, deleted: count };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rs = await store.querySql(`
|
||||||
|
SELECT id FROM nodes n
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM relations WHERE subject_id = n.id OR object_id = n.id)
|
||||||
|
AND n.name NOT IN ('_ROOT_', '_UNKNOWN_')
|
||||||
|
`);
|
||||||
|
|
||||||
|
const ids: number[] = [];
|
||||||
|
while (rs.goToNextRow()) {
|
||||||
|
ids.push(rs.getLong(rs.getColumnIndex('id')));
|
||||||
|
}
|
||||||
|
rs.close();
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return { success: true, message: 'No orphaned nodes found', deleted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
|
const idParams = ids.map(id => id.toString());
|
||||||
|
await store.executeSql(`DELETE FROM nodes WHERE id IN (${placeholders})`, idParams);
|
||||||
|
|
||||||
|
return { success: true, message: `Cleaned up ${ids.length} orphaned nodes`, deleted: ids.length };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, message: `Cleanup failed: ${e}`, deleted: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Chat Operations ============
|
||||||
|
|
||||||
|
async saveChatMessage(role: string, content: string, tools?: string): Promise<number> {
|
||||||
|
const store = this.assertStore();
|
||||||
|
const bucket: relationalStore.ValuesBucket = {
|
||||||
|
'role': role,
|
||||||
|
'content': content,
|
||||||
|
};
|
||||||
|
if (tools) {
|
||||||
|
bucket['tools'] = tools;
|
||||||
|
}
|
||||||
|
return await store.insert(Constants.TABLE_CHAT, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChatHistory(limit: number = 50): Promise<ChatMessage[]> {
|
||||||
|
const store = this.assertStore();
|
||||||
|
const rs = await store.querySql(
|
||||||
|
`SELECT * FROM chat_records ORDER BY id DESC LIMIT ${limit}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const messages: ChatMessage[] = [];
|
||||||
|
while (rs.goToNextRow()) {
|
||||||
|
const msg: ChatMessage = {
|
||||||
|
id: rs.getLong(rs.getColumnIndex('id')),
|
||||||
|
role: rs.getString(rs.getColumnIndex('role')),
|
||||||
|
content: rs.getString(rs.getColumnIndex('content')),
|
||||||
|
created_at: rs.getString(rs.getColumnIndex('created_at')),
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolsIdx = rs.getColumnIndex('tools');
|
||||||
|
if (toolsIdx >= 0) {
|
||||||
|
msg.tools = rs.getString(toolsIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.unshift(msg); // Reverse to chronological order
|
||||||
|
}
|
||||||
|
rs.close();
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearChatHistory(): Promise<void> {
|
||||||
|
const store = this.assertStore();
|
||||||
|
await store.executeSql('DELETE FROM chat_records');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Helper Methods ============
|
||||||
|
|
||||||
|
private rowToNode(rs: relationalStore.ResultSet): GraphNode {
|
||||||
|
return {
|
||||||
|
id: rs.getLong(rs.getColumnIndex('id')),
|
||||||
|
name: rs.getString(rs.getColumnIndex('name')),
|
||||||
|
type: rs.getString(rs.getColumnIndex('type')),
|
||||||
|
mentions: rs.getLong(rs.getColumnIndex('mentions')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowToEdge(rs: relationalStore.ResultSet): GraphEdge {
|
||||||
|
return {
|
||||||
|
id: rs.getLong(rs.getColumnIndex('id')),
|
||||||
|
source: rs.getLong(rs.getColumnIndex('subject_id')),
|
||||||
|
target: rs.getLong(rs.getColumnIndex('object_id')),
|
||||||
|
label: rs.getString(rs.getColumnIndex('relation')),
|
||||||
|
weight: rs.getDouble(rs.getColumnIndex('weight')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNow(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const mo = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const da = String(d.getDate()).padStart(2, '0');
|
||||||
|
const h = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const s = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
return `${y}-${mo}-${da} ${h}:${mi}:${s}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
175
harmonyos/entry/src/main/ets/pages/ChatPage.ets
Normal file
175
harmonyos/entry/src/main/ets/pages/ChatPage.ets
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import http from '@ohos.net.http';
|
||||||
|
import dataPreferences from '@ohos.data.preferences';
|
||||||
|
import { GraphDatabase, ChatMessage } from '../model/GraphDatabase';
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export struct ChatPage {
|
||||||
|
@State messages: ChatMessage[] = [];
|
||||||
|
@State inputText: string = '';
|
||||||
|
private db: GraphDatabase;
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
aboutToAppear() {
|
||||||
|
this.loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadHistory() {
|
||||||
|
this.messages = await this.db.getChatHistory(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage() {
|
||||||
|
if (!this.inputText.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMsg = this.inputText;
|
||||||
|
this.inputText = '';
|
||||||
|
|
||||||
|
// Save user message
|
||||||
|
await this.db.saveChatMessage('user', userMsg);
|
||||||
|
|
||||||
|
// Update UI immediately
|
||||||
|
this.messages = await this.db.getChatHistory(50);
|
||||||
|
|
||||||
|
// Call AI API
|
||||||
|
try {
|
||||||
|
const reply = await this.callAIApi(userMsg);
|
||||||
|
await this.db.saveChatMessage('assistant', reply);
|
||||||
|
this.messages = await this.db.getChatHistory(50);
|
||||||
|
} catch (e) {
|
||||||
|
await this.db.saveChatMessage('assistant', `Error: ${e.message || e}`);
|
||||||
|
this.messages = await this.db.getChatHistory(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async callAIApi(prompt: string): Promise<string> {
|
||||||
|
const ctx = getContext(this);
|
||||||
|
const pref = await dataPreferences.getPreferences(ctx, 'trulymem_config');
|
||||||
|
const baseUrl = await pref.get('base_url', 'https://api.deepseek.com');
|
||||||
|
const model = await pref.get('model', 'deepseek-chat');
|
||||||
|
const apiKey = await pref.get('api_key', '');
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return '请先在设置页面配置 API Key';
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpRequest = http.createHttp();
|
||||||
|
|
||||||
|
// Build chat history context
|
||||||
|
const history = this.messages.slice(-10);
|
||||||
|
const msgs = history.map(m => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add current prompt
|
||||||
|
msgs.push({ role: 'user', content: prompt });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await httpRequest.request(
|
||||||
|
baseUrl.replace(/\/$/, '') + '/v1/chat/completions',
|
||||||
|
{
|
||||||
|
method: http.RequestMethod.POST,
|
||||||
|
header: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + apiKey,
|
||||||
|
},
|
||||||
|
extraData: JSON.stringify({
|
||||||
|
model: model,
|
||||||
|
messages: msgs,
|
||||||
|
max_tokens: 2048,
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
connectTimeout: 30000,
|
||||||
|
readTimeout: 60000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
httpRequest.destroy();
|
||||||
|
|
||||||
|
const statusCode = response.responseCode;
|
||||||
|
if (statusCode !== 200) {
|
||||||
|
return `API 请求失败 (${statusCode}): ${response.result.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(response.result.toString());
|
||||||
|
if (data.choices && data.choices.length > 0) {
|
||||||
|
return data.choices[0].message.content;
|
||||||
|
}
|
||||||
|
return 'AI 返回为空';
|
||||||
|
} catch (e) {
|
||||||
|
httpRequest.destroy();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column() {
|
||||||
|
// Message list
|
||||||
|
List({ scroller: this.scroller }) {
|
||||||
|
ForEach(this.messages, (msg: ChatMessage) => {
|
||||||
|
ListItem() {
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
Row() {
|
||||||
|
Blank()
|
||||||
|
Column() {
|
||||||
|
Text(msg.content)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor('#ffffff')
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
.backgroundColor('#0052cc')
|
||||||
|
.borderRadius(12)
|
||||||
|
.maxWidth('80%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 4, bottom: 4 })
|
||||||
|
} else {
|
||||||
|
Row() {
|
||||||
|
Column() {
|
||||||
|
Text(msg.content)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor('#e0e0e0')
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
.backgroundColor('#2d2d3a')
|
||||||
|
.borderRadius(12)
|
||||||
|
.maxWidth('80%')
|
||||||
|
Blank()
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 4, bottom: 4 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, (msg: ChatMessage) => msg.id.toString())
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
|
||||||
|
// Input area
|
||||||
|
Row() {
|
||||||
|
TextArea({ text: this.inputText, placeholder: '输入消息...' })
|
||||||
|
.layoutWeight(1)
|
||||||
|
.maxLines(4)
|
||||||
|
.onChange((v: string) => {
|
||||||
|
this.inputText = v;
|
||||||
|
})
|
||||||
|
.margin({ left: 8, right: 4 })
|
||||||
|
.backgroundColor('#1e1e2e')
|
||||||
|
.fontColor('#ffffff')
|
||||||
|
|
||||||
|
Button('发送')
|
||||||
|
.onClick(() => this.sendMessage())
|
||||||
|
.margin({ left: 4, right: 8 })
|
||||||
|
.backgroundColor('#0052cc')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height(64)
|
||||||
|
.backgroundColor('#16162a')
|
||||||
|
.padding({ top: 8, bottom: 8 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.backgroundColor('#0a0a1a')
|
||||||
|
}
|
||||||
|
}
|
||||||
36
harmonyos/entry/src/main/ets/pages/GraphPage.ets
Normal file
36
harmonyos/entry/src/main/ets/pages/GraphPage.ets
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import web_webview from '@ohos.web.webview';
|
||||||
|
import { GraphDatabase } from '../model/GraphDatabase';
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export struct GraphPage {
|
||||||
|
private controller: web_webview.WebviewController = new web_webview.WebviewController();
|
||||||
|
private db: GraphDatabase;
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column() {
|
||||||
|
Web({ src: $rawfile('graph.html'), controller: this.controller })
|
||||||
|
.javaScriptAccess(true)
|
||||||
|
.onMessageReceive((msg) => {
|
||||||
|
if (msg.data === 'request_graph_data') {
|
||||||
|
this.sendGraphData();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendGraphData() {
|
||||||
|
const result = await this.db.introspect();
|
||||||
|
if (result.success) {
|
||||||
|
const jsonStr = JSON.stringify(result.data);
|
||||||
|
this.controller.runJavaScriptExt('loadGraphData(' + jsonStr + ')', (error, data) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('runJavaScriptExt error: ' + JSON.stringify(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
harmonyos/entry/src/main/ets/pages/Index.ets
Normal file
43
harmonyos/entry/src/main/ets/pages/Index.ets
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import GraphPage from './GraphPage';
|
||||||
|
import ChatPage from './ChatPage';
|
||||||
|
import SettingsPage from './SettingsPage';
|
||||||
|
import { GraphDatabase } from '../model/GraphDatabase';
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct Index {
|
||||||
|
@State currentIndex: number = 0;
|
||||||
|
private db: GraphDatabase = new GraphDatabase();
|
||||||
|
|
||||||
|
aboutToAppear() {
|
||||||
|
this.db.init(getContext(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column() {
|
||||||
|
Tabs({ index: this.currentIndex, barPosition: BarPosition.End }) {
|
||||||
|
TabContent() {
|
||||||
|
GraphPage({ db: this.db })
|
||||||
|
}
|
||||||
|
.tabBar('🌌 星图')
|
||||||
|
|
||||||
|
TabContent() {
|
||||||
|
ChatPage({ db: this.db })
|
||||||
|
}
|
||||||
|
.tabBar('💬 聊天')
|
||||||
|
|
||||||
|
TabContent() {
|
||||||
|
SettingsPage()
|
||||||
|
}
|
||||||
|
.tabBar('⚙ 设置')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.onChange((index: number) => {
|
||||||
|
this.currentIndex = index;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
}
|
||||||
97
harmonyos/entry/src/main/ets/pages/SettingsPage.ets
Normal file
97
harmonyos/entry/src/main/ets/pages/SettingsPage.ets
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import dataPreferences from '@ohos.data.preferences';
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export struct SettingsPage {
|
||||||
|
@State baseUrl: string = '';
|
||||||
|
@State model: string = '';
|
||||||
|
@State apiKey: string = '';
|
||||||
|
private pref: dataPreferences.Preferences;
|
||||||
|
|
||||||
|
async aboutToAppear() {
|
||||||
|
const ctx = getContext(this);
|
||||||
|
this.pref = await dataPreferences.getPreferences(ctx, 'trulymem_config');
|
||||||
|
this.baseUrl = await this.pref.get('base_url', 'https://api.deepseek.com');
|
||||||
|
this.model = await this.pref.get('model', 'deepseek-chat');
|
||||||
|
this.apiKey = await this.pref.get('api_key', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onBaseUrlChange(value: string) {
|
||||||
|
this.baseUrl = value;
|
||||||
|
await this.pref.put('base_url', value);
|
||||||
|
await this.pref.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModelChange(value: string) {
|
||||||
|
this.model = value;
|
||||||
|
await this.pref.put('model', value);
|
||||||
|
await this.pref.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onApiKeyChange(value: string) {
|
||||||
|
this.apiKey = value;
|
||||||
|
await this.pref.put('api_key', value);
|
||||||
|
await this.pref.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column() {
|
||||||
|
Text('API 配置')
|
||||||
|
.fontSize(24)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#ffffff')
|
||||||
|
.margin({ top: 24, bottom: 24 })
|
||||||
|
|
||||||
|
// Base URL
|
||||||
|
Text('Base URL')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor('#888899')
|
||||||
|
.width('100%')
|
||||||
|
.margin({ left: 16, bottom: 4 })
|
||||||
|
|
||||||
|
TextInput({ placeholder: 'https://api.deepseek.com', text: this.baseUrl })
|
||||||
|
.onChange((v: string) => this.onBaseUrlChange(v))
|
||||||
|
.margin({ left: 16, right: 16, bottom: 16 })
|
||||||
|
.backgroundColor('#1e1e2e')
|
||||||
|
.fontColor('#ffffff')
|
||||||
|
.placeholderColor('#555566')
|
||||||
|
|
||||||
|
// Model ID
|
||||||
|
Text('Model ID')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor('#888899')
|
||||||
|
.width('100%')
|
||||||
|
.margin({ left: 16, bottom: 4 })
|
||||||
|
|
||||||
|
TextInput({ placeholder: 'deepseek-chat', text: this.model })
|
||||||
|
.onChange((v: string) => this.onModelChange(v))
|
||||||
|
.margin({ left: 16, right: 16, bottom: 16 })
|
||||||
|
.backgroundColor('#1e1e2e')
|
||||||
|
.fontColor('#ffffff')
|
||||||
|
.placeholderColor('#555566')
|
||||||
|
|
||||||
|
// API Key
|
||||||
|
Text('API Key')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor('#888899')
|
||||||
|
.width('100%')
|
||||||
|
.margin({ left: 16, bottom: 4 })
|
||||||
|
|
||||||
|
TextInput({ placeholder: 'sk-...', text: this.apiKey })
|
||||||
|
.type(InputType.Password)
|
||||||
|
.onChange((v: string) => this.onApiKeyChange(v))
|
||||||
|
.margin({ left: 16, right: 16, bottom: 16 })
|
||||||
|
.backgroundColor('#1e1e2e')
|
||||||
|
.fontColor('#ffffff')
|
||||||
|
.placeholderColor('#555566')
|
||||||
|
|
||||||
|
Text('配置会自动保存')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor('#666677')
|
||||||
|
.margin({ top: 32 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor('#0a0a1a')
|
||||||
|
}
|
||||||
|
}
|
||||||
488
harmonyos/entry/src/main/ets/resources/rawfile/graph.html
Normal file
488
harmonyos/entry/src/main/ets/resources/rawfile/graph.html
Normal file
@ -0,0 +1,488 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>记忆星图 - TrulyMEM</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
background: #0a0a1a;
|
||||||
|
color: #ffffff;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
#canvas-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
canvas { display: block; }
|
||||||
|
#stats {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px; left: 20px;
|
||||||
|
background: rgba(10,10,26,0.8);
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(100,100,255,0.3);
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 100;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
#stats h3 { margin-bottom: 8px; color: #4488ff; font-size: 16px; }
|
||||||
|
#stats p { margin: 4px 0; color: #aaaacc; }
|
||||||
|
#stats span { color: #ffffff; font-weight: bold; }
|
||||||
|
#node-info {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px; right: 20px;
|
||||||
|
background: rgba(10,10,26,0.9);
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(100,100,255,0.3);
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 100;
|
||||||
|
display: none;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
#node-info h3 { color: #44ff88; margin-bottom: 8px; font-size: 16px; }
|
||||||
|
#node-info p { margin: 4px 0; color: #aaaacc; }
|
||||||
|
#loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%; left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 20px;
|
||||||
|
color: #4488ff;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="canvas-container">
|
||||||
|
<div id="stats">
|
||||||
|
<h3>✨ 记忆星图</h3>
|
||||||
|
<p>节点: <span id="node-count">0</span></p>
|
||||||
|
<p>关系: <span id="edge-count">0</span></p>
|
||||||
|
</div>
|
||||||
|
<div id="node-info">
|
||||||
|
<h3 id="info-name">节点</h3>
|
||||||
|
<p class="detail">类型: <span id="info-type">-</span></p>
|
||||||
|
<p class="detail">提及次数: <span id="info-mentions">-</span></p>
|
||||||
|
</div>
|
||||||
|
<div id="loading">🔄 加载星图...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// ============ Data Store ============
|
||||||
|
let graphNodes = [];
|
||||||
|
let graphEdges = [];
|
||||||
|
let scene, camera, renderer;
|
||||||
|
let nodeMeshes = [];
|
||||||
|
let edgeLines = [];
|
||||||
|
let raycaster, mouse;
|
||||||
|
let animationId;
|
||||||
|
let selectedObject = null;
|
||||||
|
|
||||||
|
// Force-directed layout
|
||||||
|
const FORCE_REPULSION = 800;
|
||||||
|
const FORCE_ATTRACTION = 0.005;
|
||||||
|
const FORCE_DAMPING = 0.85;
|
||||||
|
const MAX_SPEED = 2;
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
const COLORS = {
|
||||||
|
concept: 0x4488ff,
|
||||||
|
person: 0x44ff88,
|
||||||
|
location: 0xff8844,
|
||||||
|
event: 0xff44ff,
|
||||||
|
object: 0x44ffff,
|
||||||
|
idea: 0xffaa44,
|
||||||
|
default: 0x8888ff,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getColor(type) {
|
||||||
|
return COLORS[type] || COLORS.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeColor(type) {
|
||||||
|
const c = new THREE.Color(getColor(type));
|
||||||
|
return '#' + c.getHexString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Load Graph Data ============
|
||||||
|
window.loadGraphData = function(data) {
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
graphNodes = data.nodes || [];
|
||||||
|
graphEdges = data.edges || [];
|
||||||
|
document.getElementById('node-count').textContent = graphNodes.length;
|
||||||
|
document.getElementById('edge-count').textContent = graphEdges.length;
|
||||||
|
if (graphNodes.length > 0) {
|
||||||
|
initScene();
|
||||||
|
buildGraph();
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Three.js Scene ============
|
||||||
|
function initScene() {
|
||||||
|
const container = document.getElementById('canvas-container');
|
||||||
|
const w = container.clientWidth;
|
||||||
|
const h = container.clientHeight;
|
||||||
|
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
camera = new THREE.PerspectiveCamera(60, w / h, 0.1, 1000);
|
||||||
|
camera.position.set(0, 0, 40);
|
||||||
|
|
||||||
|
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
renderer.setSize(w, h);
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
renderer.setClearColor(0x0a0a1a, 1);
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
// Starfield
|
||||||
|
const starGeom = new THREE.BufferGeometry();
|
||||||
|
const starCount = 2000;
|
||||||
|
const starPos = new Float32Array(starCount * 3);
|
||||||
|
for (let i = 0; i < starCount * 3; i++) {
|
||||||
|
starPos[i] = (Math.random() - 0.5) * 200;
|
||||||
|
}
|
||||||
|
starGeom.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
|
||||||
|
const starMat = new THREE.PointsMaterial({
|
||||||
|
color: 0x444488,
|
||||||
|
size: 0.15,
|
||||||
|
transparent: true,
|
||||||
|
});
|
||||||
|
const stars = new THREE.Points(starGeom, starMat);
|
||||||
|
scene.add(stars);
|
||||||
|
|
||||||
|
// Ambient light
|
||||||
|
const ambientLight = new THREE.AmbientLight(0x404060);
|
||||||
|
scene.add(ambientLight);
|
||||||
|
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||||
|
dirLight.position.set(1, 1, 1);
|
||||||
|
scene.add(dirLight);
|
||||||
|
|
||||||
|
// Raycaster
|
||||||
|
raycaster = new THREE.Raycaster();
|
||||||
|
mouse = new THREE.Vector2();
|
||||||
|
|
||||||
|
container.addEventListener('click', onCanvasClick);
|
||||||
|
container.addEventListener('mousemove', onCanvasMove);
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
// Orbit (simple rotation via mouse drag)
|
||||||
|
let isDragging = false;
|
||||||
|
let prevMouse = { x: 0, y: 0 };
|
||||||
|
container.addEventListener('mousedown', (e) => {
|
||||||
|
if (e.target === renderer.domElement) {
|
||||||
|
isDragging = true;
|
||||||
|
prevMouse.x = e.clientX;
|
||||||
|
prevMouse.y = e.clientY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
container.addEventListener('mousemove', (e) => {
|
||||||
|
if (isDragging && scene) {
|
||||||
|
const dx = e.clientX - prevMouse.x;
|
||||||
|
const dy = e.clientY - prevMouse.y;
|
||||||
|
scene.rotation.y += dx * 0.005;
|
||||||
|
scene.rotation.x += dy * 0.005;
|
||||||
|
prevMouse.x = e.clientX;
|
||||||
|
prevMouse.y = e.clientY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
container.addEventListener('mouseup', () => { isDragging = false; });
|
||||||
|
container.addEventListener('mouseleave', () => { isDragging = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Build Graph ============
|
||||||
|
function buildGraph() {
|
||||||
|
// Create a map for quick lookup
|
||||||
|
const nodeMap = {};
|
||||||
|
graphNodes.forEach(n => { nodeMap[n.id] = n; });
|
||||||
|
|
||||||
|
// Initialize positions using force-directed layout
|
||||||
|
const positions = {};
|
||||||
|
const velocities = {};
|
||||||
|
graphNodes.forEach((n, i) => {
|
||||||
|
// Initial random positions in a sphere
|
||||||
|
const theta = Math.random() * Math.PI * 2;
|
||||||
|
const phi = Math.acos(2 * Math.random() - 1);
|
||||||
|
const r = 5 + Math.random() * 10;
|
||||||
|
positions[n.id] = {
|
||||||
|
x: r * Math.sin(phi) * Math.cos(theta),
|
||||||
|
y: r * Math.sin(phi) * Math.sin(theta),
|
||||||
|
z: r * Math.cos(phi),
|
||||||
|
};
|
||||||
|
velocities[n.id] = { x: 0, y: 0, z: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run force simulation (40 iterations for initial layout)
|
||||||
|
for (let iter = 0; iter < 60; iter++) {
|
||||||
|
// Reset forces
|
||||||
|
const forces = {};
|
||||||
|
graphNodes.forEach(n => {
|
||||||
|
forces[n.id] = { x: 0, y: 0, z: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Repulsion between all nodes
|
||||||
|
for (let i = 0; i < graphNodes.length; i++) {
|
||||||
|
for (let j = i + 1; j < graphNodes.length; j++) {
|
||||||
|
const a = graphNodes[i];
|
||||||
|
const b = graphNodes[j];
|
||||||
|
const pa = positions[a.id];
|
||||||
|
const pb = positions[b.id];
|
||||||
|
let dx = pb.x - pa.x;
|
||||||
|
let dy = pb.y - pa.y;
|
||||||
|
let dz = pb.z - pa.z;
|
||||||
|
let dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
||||||
|
let force = FORCE_REPULSION / (dist * dist);
|
||||||
|
forces[a.id].x -= force * dx / dist;
|
||||||
|
forces[a.id].y -= force * dy / dist;
|
||||||
|
forces[a.id].z -= force * dz / dist;
|
||||||
|
forces[b.id].x += force * dx / dist;
|
||||||
|
forces[b.id].y += force * dy / dist;
|
||||||
|
forces[b.id].z += force * dz / dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attraction along edges
|
||||||
|
graphEdges.forEach(e => {
|
||||||
|
const pa = positions[e.source];
|
||||||
|
const pb = positions[e.target];
|
||||||
|
if (!pa || !pb) return;
|
||||||
|
let dx = pb.x - pa.x;
|
||||||
|
let dy = pb.y - pa.y;
|
||||||
|
let dz = pb.z - pa.z;
|
||||||
|
let dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
||||||
|
let force = dist * FORCE_ATTRACTION;
|
||||||
|
forces[e.source].x += force * dx / dist;
|
||||||
|
forces[e.source].y += force * dy / dist;
|
||||||
|
forces[e.source].z += force * dz / dist;
|
||||||
|
forces[e.target].x -= force * dx / dist;
|
||||||
|
forces[e.target].y -= force * dy / dist;
|
||||||
|
forces[e.target].z -= force * dz / dist;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply forces with damping
|
||||||
|
graphNodes.forEach(n => {
|
||||||
|
velocities[n.id].x = (velocities[n.id].x + forces[n.id].x) * FORCE_DAMPING;
|
||||||
|
velocities[n.id].y = (velocities[n.id].y + forces[n.id].y) * FORCE_DAMPING;
|
||||||
|
velocities[n.id].z = (velocities[n.id].z + forces[n.id].z) * FORCE_DAMPING;
|
||||||
|
|
||||||
|
const speed = Math.sqrt(
|
||||||
|
velocities[n.id].x*velocities[n.id].x +
|
||||||
|
velocities[n.id].y*velocities[n.id].y +
|
||||||
|
velocities[n.id].z*velocities[n.id].z
|
||||||
|
);
|
||||||
|
if (speed > MAX_SPEED) {
|
||||||
|
velocities[n.id].x *= MAX_SPEED / speed;
|
||||||
|
velocities[n.id].y *= MAX_SPEED / speed;
|
||||||
|
velocities[n.id].z *= MAX_SPEED / speed;
|
||||||
|
}
|
||||||
|
|
||||||
|
positions[n.id].x += velocities[n.id].x;
|
||||||
|
positions[n.id].y += velocities[n.id].y;
|
||||||
|
positions[n.id].z += velocities[n.id].z;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create node meshes
|
||||||
|
nodeMeshes = [];
|
||||||
|
graphNodes.forEach(n => {
|
||||||
|
const pos = positions[n.id];
|
||||||
|
const size = Math.min(1.0, Math.max(0.3, 0.3 + n.mentions * 0.02));
|
||||||
|
const geom = new THREE.SphereGeometry(size, 16, 16);
|
||||||
|
const color = getColor(n.type);
|
||||||
|
const mat = new THREE.MeshPhongMaterial({
|
||||||
|
color: color,
|
||||||
|
emissive: color,
|
||||||
|
emissiveIntensity: 0.2,
|
||||||
|
specular: 0x444488,
|
||||||
|
shininess: 30,
|
||||||
|
});
|
||||||
|
const mesh = new THREE.Mesh(geom, mat);
|
||||||
|
mesh.position.set(pos.x, pos.y, pos.z);
|
||||||
|
mesh.userData = { nodeId: n.id, node: n };
|
||||||
|
scene.add(mesh);
|
||||||
|
nodeMeshes.push(mesh);
|
||||||
|
|
||||||
|
// Node label (using sprite)
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 256;
|
||||||
|
canvas.height = 64;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.fillStyle = 'rgba(10,10,26,0.6)';
|
||||||
|
ctx.roundRect(0, 0, 256, 64, 8);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.font = 'bold 20px Courier New';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillText(n.name.length > 15 ? n.name.substring(0, 15) + '...' : n.name, 128, 32);
|
||||||
|
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
const spriteMat = new THREE.SpriteMaterial({
|
||||||
|
map: texture,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const sprite = new THREE.Sprite(spriteMat);
|
||||||
|
sprite.position.set(pos.x, pos.y + size + 0.5, pos.z);
|
||||||
|
sprite.scale.set(4, 1, 1);
|
||||||
|
sprite.userData = { nodeId: n.id, isLabel: true };
|
||||||
|
scene.add(sprite);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create edge lines
|
||||||
|
edgeLines = [];
|
||||||
|
graphEdges.forEach(e => {
|
||||||
|
const pa = positions[e.source];
|
||||||
|
const pb = positions[e.target];
|
||||||
|
if (!pa || !pb) return;
|
||||||
|
|
||||||
|
const points = [
|
||||||
|
new THREE.Vector3(pa.x, pa.y, pa.z),
|
||||||
|
new THREE.Vector3(pb.x, pb.y, pb.z),
|
||||||
|
];
|
||||||
|
const geom = new THREE.BufferGeometry().setFromPoints(points);
|
||||||
|
const mat = new THREE.LineBasicMaterial({
|
||||||
|
color: 0x4488ff,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.3,
|
||||||
|
});
|
||||||
|
const line = new THREE.Line(geom, mat);
|
||||||
|
line.userData = { edge: e };
|
||||||
|
scene.add(line);
|
||||||
|
edgeLines.push(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Center camera
|
||||||
|
let cx = 0, cy = 0, cz = 0;
|
||||||
|
graphNodes.forEach(n => {
|
||||||
|
cx += positions[n.id].x;
|
||||||
|
cy += positions[n.id].y;
|
||||||
|
cz += positions[n.id].z;
|
||||||
|
});
|
||||||
|
cx /= graphNodes.length;
|
||||||
|
cy /= graphNodes.length;
|
||||||
|
cz /= graphNodes.length;
|
||||||
|
|
||||||
|
// Move everything so center is at origin
|
||||||
|
nodeMeshes.forEach(m => {
|
||||||
|
m.position.x -= cx;
|
||||||
|
m.position.y -= cy;
|
||||||
|
m.position.z -= cz;
|
||||||
|
});
|
||||||
|
edgeLines.forEach(l => {
|
||||||
|
const p = l.geometry.attributes.position;
|
||||||
|
for (let i = 0; i < p.count; i++) {
|
||||||
|
p.setXYZ(i, p.getX(i) - cx, p.getY(i) - cy, p.getZ(i) - cz);
|
||||||
|
}
|
||||||
|
p.needsUpdate = true;
|
||||||
|
});
|
||||||
|
scene.children.forEach(c => {
|
||||||
|
if (c.isSprite && c.userData.isLabel) {
|
||||||
|
c.position.x -= cx;
|
||||||
|
c.position.y -= cy;
|
||||||
|
c.position.z -= cz;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Interaction ============
|
||||||
|
function onCanvasClick(event) {
|
||||||
|
const rect = renderer.domElement.getBoundingClientRect();
|
||||||
|
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
raycaster.setFromCamera(mouse, camera);
|
||||||
|
|
||||||
|
const meshes = nodeMeshes.filter(m => m.visible);
|
||||||
|
const intersects = raycaster.intersectObjects(meshes);
|
||||||
|
|
||||||
|
if (intersects.length > 0) {
|
||||||
|
const hit = intersects[0].object;
|
||||||
|
const node = hit.userData.node;
|
||||||
|
showNodeInfo(node);
|
||||||
|
} else {
|
||||||
|
hideNodeInfo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasMove(event) {
|
||||||
|
const rect = renderer.domElement.getBoundingClientRect();
|
||||||
|
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
raycaster.setFromCamera(mouse, camera);
|
||||||
|
|
||||||
|
const meshes = nodeMeshes.filter(m => m.visible);
|
||||||
|
const intersects = raycaster.intersectObjects(meshes);
|
||||||
|
|
||||||
|
// Reset all
|
||||||
|
nodeMeshes.forEach(m => {
|
||||||
|
m.material.emissiveIntensity = 0.2;
|
||||||
|
m.scale.set(1, 1, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (intersects.length > 0) {
|
||||||
|
const hit = intersects[0].object;
|
||||||
|
hit.material.emissiveIntensity = 0.8;
|
||||||
|
hit.scale.set(1.3, 1.3, 1.3);
|
||||||
|
renderer.domElement.style.cursor = 'pointer';
|
||||||
|
} else {
|
||||||
|
renderer.domElement.style.cursor = 'default';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNodeInfo(node) {
|
||||||
|
document.getElementById('node-info').style.display = 'block';
|
||||||
|
document.getElementById('info-name').textContent = node.name;
|
||||||
|
document.getElementById('info-type').textContent = node.type || 'concept';
|
||||||
|
document.getElementById('info-mentions').textContent = String(node.mentions || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideNodeInfo() {
|
||||||
|
document.getElementById('node-info').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize() {
|
||||||
|
const container = document.getElementById('canvas-container');
|
||||||
|
const w = container.clientWidth;
|
||||||
|
const h = container.clientHeight;
|
||||||
|
if (camera && renderer) {
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(w, h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Animation Loop ============
|
||||||
|
function animate() {
|
||||||
|
animationId = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
// Slow rotation
|
||||||
|
if (scene) {
|
||||||
|
scene.rotation.y += 0.001;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Init ============
|
||||||
|
// Wait for Three.js to load
|
||||||
|
if (typeof THREE !== 'undefined') {
|
||||||
|
// Three.js already loaded, signal ready
|
||||||
|
window.parent.postMessage('request_graph_data', '*');
|
||||||
|
} else {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.parent.postMessage('request_graph_data', '*');
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
38
harmonyos/entry/src/main/module.json5
Normal file
38
harmonyos/entry/src/main/module.json5
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"module": {
|
||||||
|
"name": "entry",
|
||||||
|
"type": "entry",
|
||||||
|
"description": "$string:module_desc",
|
||||||
|
"mainElement": "EntryAbility",
|
||||||
|
"deviceTypes": ["phone", "tablet", "2in1"],
|
||||||
|
"deliveryWithInstall": true,
|
||||||
|
"installationFree": false,
|
||||||
|
"pages": "$profile:main_pages",
|
||||||
|
"abilities": [
|
||||||
|
{
|
||||||
|
"name": "EntryAbility",
|
||||||
|
"srcEntry": "./ets/entryability/EntryAbility.ets",
|
||||||
|
"description": "$string:EntryAbility_desc",
|
||||||
|
"icon": "$media:layered_image",
|
||||||
|
"label": "$string:EntryAbility_label",
|
||||||
|
"startWindowIcon": "$media:startIcon",
|
||||||
|
"startWindowBackground": "$color:start_window_background",
|
||||||
|
"exported": true,
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"entities": ["entity.system.home"],
|
||||||
|
"actions": ["action.system.home"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestPermissions": [
|
||||||
|
{
|
||||||
|
"name": "ohos.permission.INTERNET"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ohos.permission.GET_NETWORK_INFO"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
20
harmonyos/entry/src/main/resources/base/element/string.json
Normal file
20
harmonyos/entry/src/main/resources/base/element/string.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"string": [
|
||||||
|
{
|
||||||
|
"name": "app_name",
|
||||||
|
"value": "TrulyMEM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "module_desc",
|
||||||
|
"value": "True Memory Engine"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EntryAbility_desc",
|
||||||
|
"value": "TrulyMEM Main Ability"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EntryAbility_label",
|
||||||
|
"value": "TrulyMEM"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"src": [
|
||||||
|
"pages/Index"
|
||||||
|
]
|
||||||
|
}
|
||||||
5
harmonyos/hvigor/hvigor-config.json5
Normal file
5
harmonyos/hvigor/hvigor-config.json5
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"modelVersion": "5.0.5",
|
||||||
|
"dependencies": {},
|
||||||
|
"execution": {}
|
||||||
|
}
|
||||||
2
harmonyos/local.properties
Normal file
2
harmonyos/local.properties
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
hwsdk.dir=/opt/harmonyos/ohos-sdk/linux/command-line-tools/sdk/default
|
||||||
|
sdk.dir=/opt/harmonyos/ohos-sdk/linux/command-line-tools/sdk/default
|
||||||
9
harmonyos/oh-package.json5
Normal file
9
harmonyos/oh-package.json5
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"modelVersion": "5.0.5",
|
||||||
|
"description": "TrulyMEM - True Memory Engine for HarmonyOS",
|
||||||
|
"dependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"@ohos/hypium": "1.0.24",
|
||||||
|
"@ohos/hamock": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user