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 @@
+
+
+.grid.grid-cols-4.gap-2.min-h-600px
+ //- Sidebar - Conversation List
+ .bg-stone-50.rounded-lg.border.border-stone-200
+ .p-4.border-b.border-stone-200.bg-white.rounded-t-lg.flex.items-center.justify-between
+ h3.m-0.font-semibold.text-stone-800 Chats
+ button.px-2.py-1.rounded.border.border-stone-300.bg-white.text-stone-700(@click="emit('close')") ← Back
+ .p-3.max-h-60svh.overflow-y-auto
+ button.px-3.py-2.rounded.bg-blue-600.text-white.w-full.mb-2(@click="createNewConv(undefined, props.space)") + New Conversation
+ .space-y-2
+ .group.p-3.rounded-lg.border.cursor-pointer.transition-all.hover-shadow-sm(
+ v-for="c in convList"
+ :key="c.convId"
+ @click="selectConversation(c.convId, props.space)"
+ :class="selectedConvId === c.convId ? 'border-blue-500 bg-blue-50 shadow-sm' : 'border-stone-200 bg-white hover:border-stone-300'"
+ )
+ .font-medium.truncate(:class="selectedConvId === c.convId ? 'text-blue-700' : 'text-stone-800'") {{ c.title || c.convId }}
+ .text-xs.text-stone-500.mt-1 {{ c.lastPreview?.content || 'No messages' }}
+
+ //- Chat Area
+ .bg-white.rounded-lg.border.border-stone-200.flex.flex-col.col-span-3
+ .p-4.border-b.border-stone-200.flex.items-center.justify-between
+ h3.m-0.font-semibold.text-stone-800.truncate {{ currentChatTitle }}
+ .flex.items-center.gap-2
+ //- Dev test trigger
+ button.px-2.py-1.rounded.border.border-amber-300.bg-amber-50.text-amber-700(:disabled="testRunning" @click="runOrderTest") {{ testRunning ? 'Running…' : 'Run Order Test' }}
+ button.px-2.py-1.rounded.border.border-stone-300.bg-white.text-stone-700(@click="debugIds = !debugIds") {{ debugIds ? 'Hide Debug' : 'Show Debug' }}
+ button.px-2.py-1.rounded.border.border-stone-300.bg-white.text-stone-700(@click="dumpIds") Dump IDs
+ label.text-xs.flex.items-center.gap-1
+ input(type="checkbox" v-model="autoReply")
+ | Auto-reply
+ .px-4.pb-2.text-xs.text-stone-500(v-if="testLog.length")
+ div.font-medium.mb-1 Test Log:
+ pre.max-h-40.overflow-auto.bg-stone-50.p-2.rounded.border.border-stone-200 {{ testLog.join('\n') }}
+ .px-4.pb-2.text-xs.text-stone-500(v-if="debugIds")
+ div.font-medium.mb-1 Debug IDs:
+ div.mb-1
+ span.font-mono.mr-1 messageIds:
+ span.font-mono {{ messageIds.join(', ') }}
+ div
+ span.font-mono.mr-1 displayedIds:
+ span.font-mono {{ displayedMessages.map(m => m.id).join(', ') }}
+ .flex-1.overflow-y-auto.p-4.chat-pane(ref="chatPane" @scroll.passive="onChatScroll")
+ .text-center.text-stone-400.text-xs.mb-2(v-if="loadingMsgs") Loading...
+ .space-y-2(:key="listKey")
+ .flex.msg-row(:data-id="msg.id" v-for="msg in displayedMessages" :key="`${listKey}-${msg.id}`" :class="msg.role === 'user' ? 'justify-end' : 'justify-start'")
+ .px-3.py-2.rounded-lg(style="max-width: 75%"
+ :class="msg.role === 'user' ? 'bg-blue-600 text-white' : 'bg-stone-100 text-stone-800'"
+ ) {{ msg.content }}
+ .p-3.border-t.border-stone-200.flex.items-center.gap-2
+ input.flex-1.px-3.py-2.border.border-stone-300.rounded(type="text" v-model="msgInput" @keydown.enter.prevent="sendMessage(selectedConvId, undefined, props.space)" placeholder="Type a message...")
+ button.px-3.py-2.rounded.bg-blue-600.text-white(@click="sendMessage(selectedConvId, undefined, props.space)") Send
+
+
+
\ 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'