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