feat: add conversation management, persistence, and mobile app spec
Major enhancements to voice assistant with conversation persistence and mobile app planning: - Add conversation management with multiple active conversations - Implement conversation persistence (save/load from disk) - Add conversation selector UI for switching between conversations - Enhance agent prompt with per-conversation tmux sessions - Add mobile app technical specification (React Native + Expo) - Improve UI with active processes view and agent stream - Add ACP (Agent Control Protocol) server implementation Mobile spec details: - React Native with Expo SDK 54+ - NativeWind v5 for styling - Share Zod schemas/types from server - Android in-call experience (InCallManager) - Direct WebSocket connection via Tailscale - Same audio quality as web prototype 🤖 Generated with [Claude Code](https://claude.com/claude-code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
co-authored by
Claude
Happy
parent
37a363da6d
commit
4f2c2fc14a
@@ -0,0 +1,154 @@
|
||||
import { readFile, writeFile, readdir, unlink, mkdir, stat } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import type { ModelMessage } from "@ai-sdk/provider-utils";
|
||||
import { standardizePrompt } from "ai/internal";
|
||||
|
||||
const CONVERSATIONS_DIR = join(process.cwd(), "conversations");
|
||||
|
||||
interface ConversationMetadata {
|
||||
id: string;
|
||||
lastUpdated: Date;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
interface ConversationData {
|
||||
conversationId: string;
|
||||
lastUpdated: string;
|
||||
messageCount: number;
|
||||
messages: ModelMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure conversations directory exists
|
||||
*/
|
||||
async function ensureConversationsDir(): Promise<void> {
|
||||
await mkdir(CONVERSATIONS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Save conversation to disk
|
||||
*/
|
||||
export async function saveConversation(
|
||||
conversationId: string,
|
||||
messages: ModelMessage[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ensureConversationsDir();
|
||||
|
||||
const filepath = join(CONVERSATIONS_DIR, `${conversationId}.json`);
|
||||
const data: ConversationData = {
|
||||
conversationId,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
messageCount: messages.length,
|
||||
messages,
|
||||
};
|
||||
|
||||
await writeFile(filepath, JSON.stringify(data, null, 2), "utf-8");
|
||||
console.log(
|
||||
`[Persistence] Saved conversation ${conversationId} (${messages.length} messages)`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Persistence] Failed to save conversation ${conversationId}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load conversation from disk
|
||||
* Returns null if conversation doesn't exist or fails to parse
|
||||
*/
|
||||
export async function loadConversation(
|
||||
conversationId: string
|
||||
): Promise<ModelMessage[] | null> {
|
||||
try {
|
||||
const filepath = join(CONVERSATIONS_DIR, `${conversationId}.json`);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await stat(filepath);
|
||||
} catch {
|
||||
console.log(`[Persistence] Conversation ${conversationId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read and parse file
|
||||
const fileContent = await readFile(filepath, "utf-8");
|
||||
const data: ConversationData = JSON.parse(fileContent);
|
||||
|
||||
// Validate and standardize messages using AI SDK utility
|
||||
const result = await standardizePrompt({
|
||||
prompt: data.messages,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Persistence] Loaded conversation ${conversationId} (${data.messageCount} messages)`
|
||||
);
|
||||
|
||||
return result.messages as ModelMessage[];
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Persistence] Failed to load conversation ${conversationId}:`,
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all conversations with metadata
|
||||
*/
|
||||
export async function listConversations(): Promise<ConversationMetadata[]> {
|
||||
try {
|
||||
await ensureConversationsDir();
|
||||
|
||||
const files = await readdir(CONVERSATIONS_DIR);
|
||||
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
||||
|
||||
const conversations: ConversationMetadata[] = [];
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
try {
|
||||
const filepath = join(CONVERSATIONS_DIR, file);
|
||||
const fileContent = await readFile(filepath, "utf-8");
|
||||
const data: ConversationData = JSON.parse(fileContent);
|
||||
|
||||
conversations.push({
|
||||
id: data.conversationId,
|
||||
lastUpdated: new Date(data.lastUpdated),
|
||||
messageCount: data.messageCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[Persistence] Failed to read conversation ${file}:`, error);
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by lastUpdated descending
|
||||
conversations.sort((a, b) => b.lastUpdated.getTime() - a.lastUpdated.getTime());
|
||||
|
||||
return conversations;
|
||||
} catch (error) {
|
||||
console.error("[Persistence] Failed to list conversations:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete conversation from disk
|
||||
*/
|
||||
export async function deleteConversation(conversationId: string): Promise<void> {
|
||||
try {
|
||||
const filepath = join(CONVERSATIONS_DIR, `${conversationId}.json`);
|
||||
await unlink(filepath);
|
||||
console.log(`[Persistence] Deleted conversation ${conversationId}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Persistence] Failed to delete conversation ${conversationId}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user