Skip to content

Commit 71f57a4

Browse files
authored
Add platform-independent core module + desktop port adapters + WeChat migration guide
1 parent dcbdfa6 commit 71f57a4

5 files changed

Lines changed: 449 additions & 0 deletions

File tree

src/core/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* `core` — the platform-independent heart of EchoWise.
3+
*
4+
* Everything re-exported here is pure TypeScript with no dependency on Tauri,
5+
* the DOM, `fetch`, `MediaRecorder`, or any other host-specific API. It can be
6+
* consumed unchanged by the desktop app (Tauri) and by a future WeChat
7+
* mini-program built with Taro.
8+
*
9+
* Platform-specific behaviour (persistence, audio capture/playback, network
10+
* transport) is expressed through the interfaces in `./ports` and implemented
11+
* per-platform under `src/platform/*`.
12+
*/
13+
14+
/* ---------- domain types ---------- */
15+
export * from "../types";
16+
17+
/* ---------- pure business logic ---------- */
18+
export * from "../scoring";
19+
export * from "../companions";
20+
export * from "../appearance";
21+
22+
/* ---------- AI coaching pipeline (transport-agnostic) ----------
23+
* These functions take an `LLMProvider` and only build prompts / parse JSON —
24+
* the actual HTTP transport is a platform concern (see ports + adapters).
25+
*/
26+
export {
27+
companionTurn,
28+
reviewSentence,
29+
summarizeSession,
30+
ttsInstructions,
31+
} from "../providers";
32+
export type { ASRProvider, LLMProvider, TTSProvider } from "../providers";
33+
34+
/* ---------- platform port contracts ---------- */
35+
export * from "./ports";

src/core/ports.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Platform port interfaces.
3+
*
4+
* These describe every capability the app needs from the host platform. The
5+
* desktop app (Tauri) and a future WeChat mini-program (Taro) each supply a
6+
* concrete implementation, while the platform-independent `core` logic depends
7+
* only on these interfaces. See `docs/wechat-miniprogram.md` for the full
8+
* migration architecture.
9+
*
10+
* Adding this file changes no runtime behaviour: it is a pure type contract
11+
* that the existing desktop modules already satisfy (see `src/platform/desktop`).
12+
*/
13+
import type {
14+
AppearanceConfig,
15+
Companion,
16+
Conversation,
17+
ConversationSummary,
18+
DailyStat,
19+
ProviderConfig,
20+
Turn,
21+
UserMemory,
22+
} from "../types";
23+
24+
export type { ASRProvider, LLMProvider, TTSProvider } from "../providers";
25+
26+
/**
27+
* Structured persistence: companion, memory, settings, conversations, stats.
28+
*
29+
* Desktop: SQLite via `@tauri-apps/plugin-sql` (`src/db.ts`).
30+
* WeChat: cloud database or `wx.setStorage` (to be implemented in the Taro app).
31+
*/
32+
export interface DataStore {
33+
loadCompanion(): Promise<Companion>;
34+
saveCompanion(c: Companion): Promise<void>;
35+
36+
loadMemory(): Promise<UserMemory>;
37+
saveMemory(m: UserMemory): Promise<void>;
38+
39+
loadConfig(): Promise<ProviderConfig>;
40+
saveConfig(c: ProviderConfig): Promise<void>;
41+
42+
loadAppearance(): Promise<AppearanceConfig>;
43+
saveAppearance(a: AppearanceConfig): Promise<void>;
44+
45+
insertConversation(c: Conversation): Promise<void>;
46+
finalizeConversation(c: Conversation): Promise<void>;
47+
attachSummary(id: string, summary: ConversationSummary): Promise<void>;
48+
49+
insertTurn(conversationId: string, t: Turn): Promise<void>;
50+
updateTurn(id: string, patch: Partial<Turn>): Promise<void>;
51+
loadConversations(limit?: number): Promise<Conversation[]>;
52+
53+
loadStats(days?: number): Promise<DailyStat[]>;
54+
upsertStatOnEnd(dateISO: string, addedMinutes: number): Promise<void>;
55+
updateStatScores(dateISO: string, confidence: number, listening: number): Promise<void>;
56+
}
57+
58+
/**
59+
* Binary media persistence: recorded/synthesised audio, avatars, backgrounds.
60+
*
61+
* Desktop: filesystem via `@tauri-apps/plugin-fs` (`src/storage.ts`).
62+
* WeChat: `wx.saveFile` / cloud storage (to be implemented in the Taro app).
63+
*/
64+
export interface MediaStore {
65+
saveUserAudio(conversationId: string, turnId: string, blob: Blob): Promise<string>;
66+
saveAssistantAudio(conversationId: string, turnId: string, blob: Blob): Promise<string>;
67+
audioSrc(relPath: string): Promise<string>;
68+
readAudioBlob(relPath: string): Promise<Blob>;
69+
70+
saveAvatarFile(file: File): Promise<string>;
71+
avatarUploadSrc(uploadRef: string): Promise<string>;
72+
73+
saveBackgroundImage(file: File): Promise<string>;
74+
backgroundSrc(ref: string): Promise<string | undefined>;
75+
}
76+
77+
/** A handle to in-progress audio playback that can be stopped early. */
78+
export interface PlaybackHandle {
79+
stop(): void;
80+
onEnded(cb: () => void): void;
81+
}
82+
83+
/**
84+
* Microphone capture with a live 0..1 input level for the waveform UI.
85+
*
86+
* Desktop: `MediaRecorder` + `AudioContext` (`src/audio.ts` `Recorder`).
87+
* WeChat: `wx.getRecorderManager` + `onFrameRecorded` (Taro app).
88+
*/
89+
export interface AudioRecorder {
90+
start(): Promise<void>;
91+
/** Subscribe to live volume updates (0..1) while recording. */
92+
onLevel(cb: (level: number) => void): void;
93+
/** Stop recording and resolve with the captured audio. */
94+
stop(): Promise<Blob>;
95+
/** Stop recording and discard the audio. */
96+
cancel(): void;
97+
isRecording(): boolean;
98+
}
99+
100+
/**
101+
* Audio playback.
102+
*
103+
* Desktop: `HTMLAudioElement` (`src/audio.ts` `playBlob`).
104+
* WeChat: `wx.createInnerAudioContext` (Taro app).
105+
*/
106+
export interface AudioPlayer {
107+
play(blob: Blob): Promise<PlaybackHandle>;
108+
}
109+
110+
/** Everything the app needs from its host platform. */
111+
export interface Platform {
112+
data: DataStore;
113+
media: MediaStore;
114+
createRecorder(): AudioRecorder;
115+
createPlayer(): AudioPlayer;
116+
}

src/platform/desktop/index.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Desktop (Tauri) implementation of the platform ports.
3+
*
4+
* These are thin adapters that delegate to the existing desktop modules
5+
* (`src/db.ts`, `src/storage.ts`, `src/audio.ts`, `src/providers.ts`). They add
6+
* no new behaviour — they simply prove that the ports in `core/ports.ts`
7+
* accurately describe what the desktop app already does, and give the app (and
8+
* a future Taro app) a single, swappable `Platform` object to depend on.
9+
*/
10+
import * as db from "../../db";
11+
import * as storage from "../../storage";
12+
import { Recorder, playBlob } from "../../audio";
13+
import { CompatASR, CompatLLM, CompatTTS } from "../../providers";
14+
import type { ProviderConfig } from "../../types";
15+
import type {
16+
AudioPlayer,
17+
AudioRecorder,
18+
DataStore,
19+
MediaStore,
20+
Platform,
21+
PlaybackHandle,
22+
} from "../../core/ports";
23+
24+
/** SQLite-backed structured store. */
25+
export const desktopDataStore: DataStore = {
26+
loadCompanion: db.loadCompanion,
27+
saveCompanion: db.saveCompanion,
28+
loadMemory: db.loadMemory,
29+
saveMemory: db.saveMemory,
30+
loadConfig: db.loadConfig,
31+
saveConfig: db.saveConfig,
32+
loadAppearance: db.loadAppearance,
33+
saveAppearance: db.saveAppearance,
34+
insertConversation: db.insertConversation,
35+
finalizeConversation: db.finalizeConversation,
36+
attachSummary: db.attachSummary,
37+
insertTurn: db.insertTurn,
38+
updateTurn: db.updateTurn,
39+
loadConversations: db.loadConversations,
40+
loadStats: db.loadStats,
41+
upsertStatOnEnd: db.upsertStatOnEnd,
42+
updateStatScores: db.updateStatScores,
43+
};
44+
45+
/** Filesystem-backed media store. */
46+
export const desktopMediaStore: MediaStore = {
47+
saveUserAudio: storage.saveUserAudio,
48+
saveAssistantAudio: storage.saveAssistantAudio,
49+
audioSrc: storage.audioSrc,
50+
readAudioBlob: storage.readAudioBlob,
51+
saveAvatarFile: storage.saveAvatarFile,
52+
avatarUploadSrc: storage.avatarUploadSrc,
53+
saveBackgroundImage: storage.saveBackgroundImage,
54+
backgroundSrc: storage.backgroundSrc,
55+
};
56+
57+
/** `MediaRecorder` + `AudioContext` recorder (matches `AudioRecorder`). */
58+
export function createDesktopRecorder(): AudioRecorder {
59+
return new Recorder();
60+
}
61+
62+
/** `HTMLAudioElement` playback wrapped as a platform-neutral `PlaybackHandle`. */
63+
export function createDesktopPlayer(): AudioPlayer {
64+
return {
65+
async play(blob: Blob): Promise<PlaybackHandle> {
66+
const el = await playBlob(blob);
67+
return {
68+
stop() {
69+
try {
70+
el.pause();
71+
el.currentTime = 0;
72+
} catch {
73+
/* ignore */
74+
}
75+
},
76+
onEnded(cb: () => void) {
77+
el.addEventListener("ended", cb);
78+
},
79+
};
80+
},
81+
};
82+
}
83+
84+
/** AI transport (ASR / LLM / TTS) over `fetch`, bound to a provider config. */
85+
export function createDesktopNetwork(cfg: ProviderConfig) {
86+
return {
87+
asr: new CompatASR(cfg),
88+
llm: new CompatLLM(cfg),
89+
tts: new CompatTTS(cfg),
90+
};
91+
}
92+
93+
/** The complete desktop platform. */
94+
export const desktopPlatform: Platform = {
95+
data: desktopDataStore,
96+
media: desktopMediaStore,
97+
createRecorder: createDesktopRecorder,
98+
createPlayer: createDesktopPlayer,
99+
};

tests/core/index.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import * as core from "../../src/core";
4+
import * as types from "../../src/types";
5+
import * as scoring from "../../src/scoring";
6+
import * as companions from "../../src/companions";
7+
import * as appearance from "../../src/appearance";
8+
import {
9+
companionTurn,
10+
reviewSentence,
11+
summarizeSession,
12+
ttsInstructions,
13+
} from "../../src/providers";
14+
15+
/**
16+
* The `core` barrel must expose the platform-independent logic *by reference*
17+
* (re-exports, not copies) so desktop and mini-program share one source of
18+
* truth. It must NOT pull in any host-specific transport (e.g. the fetch-based
19+
* Compat* classes stay in the platform layer).
20+
*/
21+
describe("core barrel", () => {
22+
it("re-exports domain types/helpers by reference", () => {
23+
expect(core.tierForDay).toBe(types.tierForDay);
24+
expect(core.dayCount).toBe(types.dayCount);
25+
expect(core.RELATIONSHIP_TIERS).toBe(types.RELATIONSHIP_TIERS);
26+
});
27+
28+
it("re-exports pure business logic by reference", () => {
29+
// scoring
30+
for (const k of Object.keys(scoring) as (keyof typeof scoring)[]) {
31+
expect((core as Record<string, unknown>)[k]).toBe(scoring[k]);
32+
}
33+
// companions
34+
for (const k of Object.keys(companions) as (keyof typeof companions)[]) {
35+
expect((core as Record<string, unknown>)[k]).toBe(companions[k]);
36+
}
37+
// appearance
38+
for (const k of Object.keys(appearance) as (keyof typeof appearance)[]) {
39+
expect((core as Record<string, unknown>)[k]).toBe(appearance[k]);
40+
}
41+
});
42+
43+
it("re-exports the transport-agnostic coaching pipeline", () => {
44+
expect(core.companionTurn).toBe(companionTurn);
45+
expect(core.reviewSentence).toBe(reviewSentence);
46+
expect(core.summarizeSession).toBe(summarizeSession);
47+
expect(core.ttsInstructions).toBe(ttsInstructions);
48+
});
49+
50+
it("does not leak host-specific transport classes into core", () => {
51+
expect((core as Record<string, unknown>).CompatASR).toBeUndefined();
52+
expect((core as Record<string, unknown>).CompatLLM).toBeUndefined();
53+
expect((core as Record<string, unknown>).CompatTTS).toBeUndefined();
54+
});
55+
});

0 commit comments

Comments
 (0)