From b37fd943d467f67a81b25b7dafec77d76fffb104 Mon Sep 17 00:00:00 2001 From: ponzs Date: Wed, 3 Sep 2025 09:25:41 +0800 Subject: [PATCH] feat(chat): add ChatDemo and related updates --- src/HashFS.vue | 25 ++- src/chat/ChatDemo.vue | 411 +++++++++++++++++++++++++++++++++++++++ src/chat/README.md | 218 +++++++++++++++++++++ src/chat/README.zh-CN.md | 229 ++++++++++++++++++++++ src/chat/useChat.js | 297 ++++++++++++++++++++++++++++ src/index.js | 4 +- 6 files changed, 1181 insertions(+), 3 deletions(-) create mode 100644 src/chat/ChatDemo.vue create mode 100644 src/chat/README.md create mode 100644 src/chat/README.zh-CN.md create mode 100644 src/chat/useChat.js diff --git a/src/HashFS.vue b/src/HashFS.vue index b3aa2de..0b6e7ff 100644 --- a/src/HashFS.vue +++ b/src/HashFS.vue @@ -1,6 +1,7 @@ + + + + \ No newline at end of file diff --git a/src/chat/README.md b/src/chat/README.md new file mode 100644 index 0000000..5b44f07 --- /dev/null +++ b/src/chat/README.md @@ -0,0 +1,218 @@ +# Chat Module + + English | [中文](./README.zh-CN.md) + + This folder contains the chat composables and demo UI built on top of HashFS storage. + + - useChat.js: A Vue composable that provides a file-backed chat API (conversations, messages, pagination) using HashFS + IndexedDB worker. + - ChatDemo.vue: A simple but production-like chat UI demonstrating typical usage patterns, including history pagination, strict ordering, scroll anchoring, and message sending. + + ## Getting Started + + Import the composable from the library entry: + + ```js + import { useChat } from '../index.js' + ``` + + Create an instance (you MUST pass namespace and chunkSize at creation; space MUST be passed per API call explicitly): + + ```js + const chat = useChat({ namespace: 'chat', chunkSize: 200 }) + await chat.init() + ``` + + Note: Make sure you are authenticated with HashFS (useHashFS) before calling any API. + +### useChat(options) +- options.namespace: required, string. Namespace prefix to isolate chat data (e.g., 'chat'). +- options.chunkSize: required, positive number. Max number of lines per NDJSON chunk file (impacts history read efficiency). +- options.space: not used at instantiation; you MUST pass { space } explicitly on every API call. Passing an empty string means using only the namespace root. +- chat.init(): lightweight initialization; ensures the worker is ready (does not perform authentication). + + ## API Reference with Practical Examples + + All APIs require you to pass space explicitly on each call. + + ### 1) createConversation({ convId?, title?, space }) => Promise + Create a new conversation and register it in the global sequence index. + + Parameters: + - convId: optional, string. If omitted, an id will be generated like `c--`. + - title: optional, string. Default 'New Chat'. + - space: required, string. Business/tenant space; empty string is allowed to use the namespace root. + + Returns Meta: + - convId: conversation id + - title: conversation title + - lastId: latest auto-increment message id (initially 0) + - lastChunkIndex: index of the last message chunk (initially 0) + - updatedAt: last updated timestamp (ms) + - lastPreview: preview of the latest message `{ id, role, content, ts }` or null + - seq: global sequence number for conversation list ordering (descending, bigger is newer) + + Possible errors: + - Not authenticated + - Missing space (space must be a string) + + Example: + ```js + const { convId } = await chat.createConversation({ title: 'Alice', space: 'myApp' }) + ``` + + ### 2) listConversations({ page, pageSize, space }) => Promise> + List recent conversations using the global seq index (newest first). Deduplicated by convId. + + Parameters: + - page: required, positive integer. Page number starting from 1. + - pageSize: required, positive integer. Page size. + - space: required, string. Business/tenant space. + + Returns Array where each Summary is: + - { seq, convId, title, updatedAt, lastId, lastPreview } + - seq: global sequence number (larger is newer) + - lastPreview: `{ id, role, content, ts }` or null + + Possible errors: + - page or pageSize is not a positive integer + - Not authenticated or missing space + + Example: + ```js + const list = await chat.listConversations({ page: 1, pageSize: 20, space: 'myApp' }) + ``` + + ### 3) getConversationPreview(convId, { space }) => Promise<{ convId, title, updatedAt, lastId, lastPreview } | null> + Fetch the latest preview for a conversation without loading messages. + + Parameters: + - convId: required, string. + - space: required, string. + + Returns: + - If the conversation exists: `{ convId, title, updatedAt, lastId, lastPreview }` + - If it does not exist: null + + Possible errors: + - Not authenticated or missing space + + Example: + ```js + const info = await chat.getConversationPreview(convId, { space: 'myApp' }) + ``` + + ### 4) getLatestMessage(convId, { space }) => Promise + Fast path to retrieve the latest message of a conversation. + + Parameters: + - convId: required, string. + - space: required, string. + + Returns Message: + - Fields: { id, role, content, ts, ...custom } + - Note: Message object does not include convId; ts is a millisecond timestamp. + - Returns null if the conversation does not exist or has no messages. + + Possible errors: + - Not authenticated or missing space + + Example: + ```js + const latest = await chat.getLatestMessage(convId, { space: 'myApp' }) + ``` + + ### 5) loadHistory({ convId, beforeId?, limit, space }) => Promise> + Load history in ascending order by id; optionally before a given message id. + + Parameters: + - convId: required, string. + - beforeId: optional, positive integer. If provided, returns messages with id ≤ beforeId; otherwise returns the latest window. + - limit: required, positive integer. Max number of messages to return. + - space: required, string. + + Returns: + - Array sorted ascending by id. Message has the same fields as above `{ id, role, content, ts, ... }`. + + Possible errors: + - limit is not a positive integer + - Not authenticated or missing space + + Example: + ```js + const latestPage = await chat.loadHistory({ convId, limit: 20, space: 'myApp' }) + const older = await chat.loadHistory({ convId, beforeId: latestPage[0].id - 1, limit: 20, space: 'myApp' }) + ``` + + ### 6) addMessage({ convId, message, space }) => Promise + Append a message and bump the global sequence snapshot (so the conversation moves to the top and preview updates). + + Parameters: + - convId: required, string. + - message: required, object. + - role: optional, string, default 'user'. + - content: optional, string, default ''. + - other custom fields are allowed and will be stored as-is. + - space: required, string. + + Returns: + - The stored Message including assigned auto-increment id and write timestamp ts. + + Possible errors: + - Not authenticated or missing space + + Example: + ```js + const msg = await chat.addMessage({ convId, message: { role: 'user', content: 'Hello!' }, space: 'myApp' }) + ``` + + ### 7) setConversationTitle(convId, title, { space }) => Promise + Rename a conversation and write a new seq snapshot for list ordering. + + Parameters: + - convId: required, string. + - title: required, string. + - space: required, string. + + Returns: + - true on success. + + Possible errors: + - Not authenticated or missing space + + Example: + ```js + await chat.setConversationTitle(convId, 'Project X', { space: 'myApp' }) + ``` + + ## End-to-End Example + + ```js + import { useChat } from '../index.js' + + const chat = useChat({ namespace: 'chat', chunkSize: 200 }) + await chat.init() + + // ensure a conversation + const { convId } = await chat.createConversation({ title: 'Support', space: 'prod' }) + + // initial window + let messages = await chat.loadHistory({ convId, limit: 30, space: 'prod' }) + + // prepend older when scrolled to top + const firstId = messages[0]?.id + if (firstId > 1) { + const older = await chat.loadHistory({ convId, limit: 30, beforeId: firstId - 1, space: 'prod' }) + messages = [...older, ...messages] // messages stay sorted ascending + } + + // send a message + await chat.addMessage({ convId, message: { role: 'user', content: 'Hi there' }, space: 'prod' }) + ``` + + ## Demo UI Tips (ChatDemo.vue) + - Keeps internal ids strictly ascending and unique when merging. + - Forces DOM remount on history prepend to avoid node reuse. + - Precise scroll anchoring: captures first visible row and restores its visual offset after merge. + - Includes a test harness to verify ordering and debug id sequences. + + Feel free to copy parts of ChatDemo.vue into your own app. \ No newline at end of file diff --git a/src/chat/README.zh-CN.md b/src/chat/README.zh-CN.md new file mode 100644 index 0000000..e9598d8 --- /dev/null +++ b/src/chat/README.zh-CN.md @@ -0,0 +1,229 @@ +# 聊天模块(中文) + +Language: [English](./README.md) | 中文 + +该目录包含基于 HashFS 存储实现的聊天组合式 API 与示例界面。 + +- useChat.js:提供文件存储驱动的聊天 API(会话、消息、分页) +- ChatDemo.vue:一个接近生产实践的聊天 UI,演示历史分页、严格顺序、滚动锚定与发送消息 + +## 快速开始 + +从库入口导入组合式 API: + +```js +import { useChat } from '../index.js' +``` + +创建实例(注意:namespace、chunkSize 在创建时传入;space 必须在调用每个 API 时显式传入): + +```js +const chat = useChat({ namespace: 'chat', chunkSize: 200 }) +await chat.init() +``` + +提示:在调用聊天 API 前,应确保已通过 useHashFS 完成鉴权初始化。 + +### useChat(options) 参数说明 +- options.namespace: 必填,字符串。用于隔离聊天数据的命名空间前缀(例如 'chat')。 +- options.chunkSize: 必填,正数。消息分片文件的最大行数(影响历史分页的读取效率)。 +- options.space: 不在实例化时使用;每次调用 API 必须显式传入 { space },支持传入空字符串表示仅使用 namespace 根目录。 +- chat.init(): 轻量初始化,确保底层 Worker 可用;不做鉴权,调用各业务方法前需要已完成鉴权。 + +## API 参考与实用示例 + +以下 API 默认使用全局顺序索引(seq)保证多会话列表按最新更新时间倒序展示,单会话内消息严格按 id 递增展示。 + +### 1) createConversation({ convId?, title?, space }) => Promise +创建一个新会话,并在全局顺序索引中注册。 + +参数: +- convId: 可选,字符串。未提供将自动生成(形式如 `c--`)。 +- title: 可选,字符串。默认 'New Chat'。 +- space: 必填,字符串。用于区分业务空间;空字符串表示使用 namespace 根目录。 + +返回值 Meta: +- convId: 会话 ID +- title: 会话标题 +- lastId: 最后一条消息的自增 ID(初始为 0) +- lastChunkIndex: 最后一条消息所在分片索引(初始为 0) +- updatedAt: 最近更新时间(毫秒) +- lastPreview: 最近一条消息的预览 `{ id, role, content, ts }` 或 null +- seq: 全局顺序号(用于会话列表倒序) + +可能错误: +- 未鉴权时抛出 `Not authenticated` +- 未传 space 时抛出 `space is required...` + +示例: +```js +const { convId } = await chat.createConversation({ title: 'Alice', space: 'myApp' }) +``` + +### 2) listConversations({ page, pageSize, space }) => Promise> +分页列出最近会话(按 seq 倒序),按 convId 去重。 + +参数: +- page: 必填,正整数。页码,从 1 开始。 +- pageSize: 必填,正整数。每页条数。 +- space: 必填,字符串。业务空间。 + +返回值:Array +- Summary 字段:{ seq, convId, title, updatedAt, lastId, lastPreview } + - seq: 全局顺序号(越大越新) + - lastPreview: 最近一条消息的预览 `{ id, role, content, ts }` 或 null + +可能错误: +- `page` 或 `pageSize` 非正整数将抛错 +- 未鉴权或缺少 space 将抛错 + +示例: +```js +const convs = await chat.listConversations({ page: 1, pageSize: 20, space: 'myApp' }) +``` + +### 3) getConversationPreview(convId, { space }) => Promise<{ convId, title, updatedAt, lastId, lastPreview } | null> +获取指定会话的最新预览与更新时间(便于渲染会话列表)。 + +参数: +- convId: 必填,字符串。 +- space: 必填,字符串。 + +返回值: +- 若存在,返回 `{ convId, title, updatedAt, lastId, lastPreview }` +- 若不存在该会话,返回 null + +可能错误: +- 未鉴权或缺少 space 将抛错 + +示例: +```js +const info = await chat.getConversationPreview(convId, { space: 'myApp' }) +``` + +### 4) getLatestMessage(convId, { space }) => Promise +获取某会话最新一条消息。 + +参数: +- convId: 必填,字符串。 +- space: 必填,字符串。 + +返回值 Message: +- 字段:{ id, role, content, ts, ...自定义扩展 } + - 注意:消息对象不包含 convId 字段;`ts` 为毫秒时间戳。 +- 若会话不存在或无消息,返回 null + +可能错误: +- 未鉴权或缺少 space 将抛错 + +示例: +```js +const latest = await chat.getLatestMessage(convId, { space: 'myApp' }) +``` + +### 5) loadHistory({ convId, beforeId?, limit, space }) => Promise> +向上分页加载历史消息(返回按 id 升序)。 + +参数: +- convId: 必填,字符串。 +- beforeId: 可选,正整数。若提供,则返回 id ≤ beforeId 的最近若干条;未提供则取最新一页。 +- limit: 必填,正整数。返回的最大消息数。 +- space: 必填,字符串。 + +返回值: +- Array,按 id 升序排列。Message 字段同上 `{ id, role, content, ts, ... }`。 + +可能错误: +- `limit` 非正整数将抛错 +- 未鉴权或缺少 space 将抛错 + +示例: +```js +const latestPage = await chat.loadHistory({ convId, limit: 20, space: 'myApp' }) +const older = await chat.loadHistory({ convId, beforeId: latestPage[0].id - 1, limit: 20, space: 'myApp' }) +``` + +### 6) addMessage({ convId, message, space }) => Promise +向会话追加一条消息,并更新全局会话顺序(用于会话列表置顶和预览内容)。 + +参数: +- convId: 必填,字符串。 +- message: 必填,对象。 + - role: 可选,字符串,默认 'user'。 + - content: 可选,字符串,默认空串。 + - 其他自定义字段:允许,会被原样存储。 +- space: 必填,字符串。 + +返回值: +- 写入后的 Message 对象,包含自增 `id` 与写入时刻 `ts`。 + +可能错误: +- 未鉴权或缺少 space 将抛错 + +示例: +```js +await chat.addMessage({ convId, message: { role: 'user', content: 'Hello' }, space: 'myApp' }) +``` + +### 7) setConversationTitle(convId, title, { space }) => Promise +设置会话标题,同时刷新列表展示(写入新的 by-seq 快照)。 + +参数: +- convId: 必填,字符串。 +- title: 必填,字符串。 +- space: 必填,字符串。 + +返回值: +- 成功返回 true。 + +可能错误: +- 未鉴权或缺少 space 将抛错 + +示例: +```js +await chat.setConversationTitle(convId, '新的对话标题', { space: 'myApp' }) +``` + +## 端到端示例 + +```js +import { useChat } from '../index.js' + +const chat = useChat({ namespace: 'chat', chunkSize: 200 }) +await chat.init() + +// 1) 新建会话 +const { convId } = await chat.createConversation({ title: 'Alice', space: 'myApp' }) + +// 2) 发送消息 +await chat.addMessage({ convId, message: { role: 'user', content: 'Hello Alice' }, space: 'myApp' }) +await chat.addMessage({ convId, message: { role: 'assistant', content: 'Hi! How can I help you?' }, space: 'myApp' }) + +// 3) 拉取最新一页(升序) +let messages = await chat.loadHistory({ convId, limit: 20, space: 'myApp' }) +// 4) 继续向上分页 +if (messages[0]?.id > 1) { + const older = await chat.loadHistory({ convId, beforeId: messages[0].id - 1, limit: 20, space: 'myApp' }) + messages = [...older, ...messages] +} + +// 5) 会话列表 +const convs = await chat.listConversations({ page: 1, pageSize: 20, space: 'myApp' }) +``` + +## Demo 组件使用提示(ChatDemo.vue) + +- 演示了: + - 历史分页(loadMore)与严格顺序合并 + - 滚动锚定:加载历史时捕获并恢复第一个可见消息的 data-id 与偏移 + - DOM 复用打破:容器 key 与行级 key 结合,确保 DOM 顺序与数据一致 +- 如需在你的页面集成 Demo: + - 从库入口导出:`import { ChatDemo } from '../index.js'` + - 或直接引用文件:`import ChatDemo from './chat/ChatDemo.vue'` + +## 最佳实践建议 + +- 按需调整每页条数(limit)以匹配视口高度,减少滚动抖动 +- 避免连续快速触发多次加载;可在 onScroll 中加入节流/队列化策略 +- 发送与加载并发时,使用 id 序号严格合并,避免 DOM 顺序错位 +- 出错处理要覆盖:索引损坏、分片丢失、空间切换与权限异常 \ No newline at end of file diff --git a/src/chat/useChat.js b/src/chat/useChat.js new file mode 100644 index 0000000..f8147c6 --- /dev/null +++ b/src/chat/useChat.js @@ -0,0 +1,297 @@ +import { ref } from 'vue'; +import { state, WM, encoder, decoder } from '../useHashFS.js'; + +export function useChat(options = {}) { + + const cfg = { ...options }; + if (!cfg.namespace || typeof cfg.namespace !== 'string') { + throw new Error('useChat: options.namespace is required'); + } + if (!Number.isFinite(cfg.chunkSize) || cfg.chunkSize <= 0) { + throw new Error('useChat: options.chunkSize must be a positive number'); + } + + const initialized = ref(false); + + function ensureAuth() { + if (!state.auth.value) throw new Error('Not authenticated'); + } + + function cleanPart(p) { + return String(p || '').replace(/\s+/g, '').replace(/[^a-zA-Z0-9_.\-\/]/g, ''); + } + + function requireSpace(space) { + if (typeof space !== 'string') { + throw new Error('space is required for this operation (must be a string, can be empty)'); + } + return space; + } + + function root(space) { + const base = cleanPart(cfg.namespace); + const seg = cleanPart(requireSpace(space) || ''); + return seg ? `${base}/${seg}` : base; + } + + function convMetaPath(convId, space) { + return `${root(space)}/conv/${cleanPart(convId)}/meta.json`; + } + + function convChunkPath(convId, chunkIdx, space) { + return `${root(space)}/conv/${cleanPart(convId)}/chunks/${chunkIdx}.ndjson`; + } + + function seqMaxPath(space) { + return `${root(space)}/_seq_max.txt`; + } + + function bySeqPath(seq, space) { + return `${root(space)}/by-seq/${seq}.json`; + } + + async function readTextFile(filename) { + const res = await WM().sendToWorker('load', { filename }); + if (!res || !res.bytes || res.bytes.byteLength === 0) return ''; + return decoder.decode(new Uint8Array(res.bytes)); + } + + async function writeTextFile(filename, text, mime = 'text/plain') { + const bytes = encoder.encode(String(text)); + await WM().sendToWorker('save', { filename, mime, bytes: bytes.buffer.slice(0, bytes.byteLength) }); + } + + async function readJSON(filename) { + const txt = await readTextFile(filename); + if (!txt) return null; + try { return JSON.parse(txt); } catch { return null; } + } + + async function writeJSON(filename, obj) { + const txt = JSON.stringify(obj); + await writeTextFile(filename, txt, 'application/json'); + } + + async function getSeqMax(space) { + const txt = await readTextFile(seqMaxPath(space)); + const n = parseInt(txt || '0', 10); + return Number.isFinite(n) && n > 0 ? n : 0; + } + + async function setSeqMax(val, space) { + await writeTextFile(seqMaxPath(space), String(val), 'text/plain'); + } + + function nowTs() { return Date.now(); } + + // Create a conversation; returns { convId, title, lastId, updatedAt, seq } + async function createConversation({ convId = '', title = 'New Chat', space } = {}) { + ensureAuth(); + const sp = requireSpace(space); + + const id = cleanPart(convId) || `c-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const meta = { + convId: id, + title: String(title || 'New Chat'), + lastId: 0, + lastChunkIndex: 0, + updatedAt: nowTs(), + lastPreview: null + }; + + await writeJSON(convMetaPath(id, sp), meta); + + // update global sequence index + const prevSeq = await getSeqMax(sp); + const seq = prevSeq + 1; + await setSeqMax(seq, sp); + + const summary = { seq, convId: id, title: meta.title, updatedAt: meta.updatedAt, lastId: meta.lastId, lastPreview: meta.lastPreview }; + await writeJSON(bySeqPath(seq, sp), summary); + + return { ...meta, seq }; + } + + // List conversations by pagination using the global seq index + // params: { page, pageSize, space } + async function listConversations({ page, pageSize, space } = {}) { + ensureAuth(); + if (!Number.isInteger(page) || page < 1) throw new Error('listConversations: page is required and must be a positive integer'); + if (!Number.isInteger(pageSize) || pageSize < 1) throw new Error('listConversations: pageSize is required and must be a positive integer'); + const sp = requireSpace(space); + + const seqMax = await getSeqMax(sp); + if (seqMax <= 0) return []; + + // scan a bit wider to account for dedupe (heuristic factor 3) + const scanFactor = 3; + const startSeq = Math.max(1, seqMax - (page - 1) * pageSize * scanFactor); + const endSeq = Math.max(1, startSeq - pageSize * scanFactor + 1); + + const items = []; + const seen = new Set(); + for (let s = startSeq; s >= endSeq && items.length < pageSize; s--) { + const item = await readJSON(bySeqPath(s, sp)); + if (item && item.convId && !seen.has(item.convId)) { + items.push(item); + seen.add(item.convId); + } + } + + return items; // deduped, newest first by seq + } + + // Get preview for a conversation (no message scan) + async function getConversationPreview(convId, { space } = {}) { + ensureAuth(); + const sp = requireSpace(space); + const meta = await readJSON(convMetaPath(convId, sp)); + if (!meta) return null; + return { convId: meta.convId, title: meta.title, updatedAt: meta.updatedAt, lastId: meta.lastId, lastPreview: meta.lastPreview }; + } + + // Load latest message quickly (reads only the last chunk) + async function getLatestMessage(convId, { space } = {}) { + ensureAuth(); + const sp = requireSpace(space); + const meta = await readJSON(convMetaPath(convId, sp)); + if (!meta || meta.lastId <= 0) return null; + + const chunkIdx = Math.floor((meta.lastId - 1) / cfg.chunkSize); + const text = await readTextFile(convChunkPath(convId, chunkIdx, sp)); + if (!text) return null; + + const lines = text.trim().split('\n').filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + try { + const msg = JSON.parse(lines[i]); + if (msg && msg.id === meta.lastId) return msg; + } catch { /* ignore */ } + } + return null; + } + + // Load chat history: latest N messages, optionally before a message id + // params: { convId, limit, beforeId = null, space } + async function loadHistory({ convId, limit, beforeId = null, space } = {}) { + ensureAuth(); + if (!Number.isInteger(limit) || limit < 1) throw new Error('loadHistory: limit is required and must be a positive integer'); + const sp = requireSpace(space); + + const meta = await readJSON(convMetaPath(convId, sp)); + if (!meta) return []; + + const boundary = beforeId && beforeId > 0 ? Math.min(beforeId, meta.lastId) : meta.lastId; + if (!boundary || boundary <= 0) return []; + + const startId = Math.max(1, boundary - limit + 1); + const startChunk = Math.floor((startId - 1) / cfg.chunkSize); + const endChunk = Math.floor((boundary - 1) / cfg.chunkSize); + + const messages = []; + for (let chunk = endChunk; chunk >= startChunk; chunk--) { + const text = await readTextFile(convChunkPath(convId, chunk, sp)); + if (!text) continue; + const lines = text.split('\n').filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + try { + const msg = JSON.parse(lines[i]); + if (msg.id >= startId && msg.id <= boundary) { + messages.push(msg); + if (messages.length >= limit) break; + } + } catch { /* ignore parse error */ } + } + if (messages.length >= limit) break; + } + + // currently in reverse order; return ascending by id + return messages.sort((a, b) => a.id - b.id); + } + + // Append a message to conversation with auto-increment id + // params: { convId, message: { role, content, ... }, space } + async function addMessage({ convId, message, space } = {}) { + ensureAuth(); + const sp = requireSpace(space); + const metaFile = convMetaPath(convId, sp); + const meta = (await readJSON(metaFile)) || { convId, title: 'Chat', lastId: 0, lastChunkIndex: 0, updatedAt: 0, lastPreview: null }; + + const nextId = (meta.lastId || 0) + 1; + const chunkIdx = Math.floor((nextId - 1) / cfg.chunkSize); + + const msg = { + id: nextId, + role: message?.role || 'user', + content: message?.content ?? '', + ts: nowTs(), + ...message + }; + + // append to chunk + const chunkFile = convChunkPath(convId, chunkIdx, sp); + const oldText = await readTextFile(chunkFile); + const newText = (oldText ? (oldText.endsWith('\n') ? oldText : oldText + '\n') : '') + JSON.stringify(msg) + '\n'; + await writeTextFile(chunkFile, newText, 'application/x-ndjson'); + + // update meta + const previewText = String(msg.content || '').slice(0, 120); + const updatedMeta = { + ...meta, + convId, + lastId: nextId, + lastChunkIndex: chunkIdx, + updatedAt: msg.ts, + lastPreview: { id: nextId, role: msg.role, content: previewText, ts: msg.ts }, + }; + await writeJSON(metaFile, updatedMeta); + + // bump global sequence and write by-seq snapshot + const prevSeq = await getSeqMax(sp); + const seq = prevSeq + 1; + await setSeqMax(seq, sp); + const summary = { seq, convId, title: updatedMeta.title || 'Chat', updatedAt: updatedMeta.updatedAt, lastId: updatedMeta.lastId, lastPreview: updatedMeta.lastPreview }; + await writeJSON(bySeqPath(seq, sp), summary); + + return msg; + } + + // Optional helper to set conversation title + async function setConversationTitle(convId, title, { space } = {}) { + ensureAuth(); + const sp = requireSpace(space); + const metaFile = convMetaPath(convId, sp); + const meta = (await readJSON(metaFile)) || { convId, title: 'Chat', lastId: 0, lastChunkIndex: 0, updatedAt: 0, lastPreview: null }; + meta.title = String(title || 'Chat'); + await writeJSON(metaFile, meta); + + // also reflect in a new seq snapshot for ordering + const prevSeq = await getSeqMax(sp); + const seq = prevSeq + 1; + await setSeqMax(seq, sp); + const summary = { seq, convId, title: meta.title, updatedAt: meta.updatedAt, lastId: meta.lastId, lastPreview: meta.lastPreview }; + await writeJSON(bySeqPath(seq, sp), summary); + + return true; + } + + // Lightweight initialization marker (no-op aside from ensuring WM is ready) + async function init() { + if (initialized.value) return true; + // Ensure worker is alive (does not authenticate) + await WM().initWorker(); + initialized.value = true; + return true; + } + + return { + init, + createConversation, + listConversations, + getConversationPreview, + getLatestMessage, + loadHistory, + addMessage, + setConversationTitle, + }; +} \ No newline at end of file diff --git a/src/index.js b/src/index.js index e38789c..9586632 100644 --- a/src/index.js +++ b/src/index.js @@ -1,2 +1,4 @@ export { useHashFS } from './useHashFS' -export { useFile } from './useFile' \ No newline at end of file +export { useFile } from './useFile' +export { useChat } from './chat/useChat' +export { default as ChatDemo } from './chat/ChatDemo.vue'