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:
root
2026-04-28 12:38:21 +08:00
parent 14bbcfde3d
commit 7c2441682c
17 changed files with 1485 additions and 0 deletions

View File

@ -0,0 +1,10 @@
{
"app": {
"bundleName": "com.trulymem.app",
"vendor": "trulymem",
"versionCode": 1000000,
"versionName": "1.0.0",
"icon": ":layered_image",
"label": ":app_name"
}
}

View 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"
}
]
}

View File

@ -0,0 +1,10 @@
{
"apiType": "stageMode",
"buildOption": {},
"targets": [
{
"name": "default",
"runtimeOS": "HarmonyOS"
}
]
}

View 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'))
)`;
}

View 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');
}
}

View 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}`;
}
}

View 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')
}
}

View 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));
}
});
}
}
}

View 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%')
}
}

View 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')
}
}

View 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>

View 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"
}
]
}
}

View 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"
}
]
}

View File

@ -0,0 +1,5 @@
{
"src": [
"pages/Index"
]
}

View File

@ -0,0 +1,5 @@
{
"modelVersion": "5.0.5",
"dependencies": {},
"execution": {}
}

View 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

View 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"
}
}