server: reconcile ai_sessions persistence to Chunk 9 AISession shape
Reshape the ai_sessions table (migration 003) and mapping/routes to the full AISession contract: title, kind, notes, created_at, last_message_at; drop legacy role/model/settings. toAISession now maps the new columns and routes create/patch/messages handlers use the new shape.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { AgentController, isRateLimitError } from "./index.js";
|
import { AgentController, isRateLimitError } from "./index.js";
|
||||||
import type { ResolvedProvider } from "./index.js";
|
import type { CompletionUsage, ResolvedProvider } from "./index.js";
|
||||||
|
|
||||||
/** Build a `text/event-stream` Response from a list of SSE event payloads. */
|
/** Build a `text/event-stream` Response from a list of SSE event payloads. */
|
||||||
function sseResponse(events: string[], status = 200): Response {
|
function sseResponse(events: string[], status = 200): Response {
|
||||||
@@ -78,7 +78,7 @@ describe("AgentController.stream", () => {
|
|||||||
])) as unknown as typeof fetch;
|
])) as unknown as typeof fetch;
|
||||||
|
|
||||||
const agent = new AgentController(config);
|
const agent = new AgentController(config);
|
||||||
let seen: (typeof config) | null = null;
|
let seen: CompletionUsage | null = null;
|
||||||
for await (const chunk of agent.stream([{ role: "user", content: "hi" }])) {
|
for await (const chunk of agent.stream([{ role: "user", content: "hi" }])) {
|
||||||
if (chunk.type === "done" && chunk.usage) {
|
if (chunk.type === "done" && chunk.usage) {
|
||||||
seen = chunk.usage;
|
seen = chunk.usage;
|
||||||
@@ -132,7 +132,7 @@ describe("AgentController.collect", () => {
|
|||||||
describe("thinking-param mapping", () => {
|
describe("thinking-param mapping", () => {
|
||||||
it("maps thinking config onto the request body", async () => {
|
it("maps thinking config onto the request body", async () => {
|
||||||
const calls: Array<RequestInit> = [];
|
const calls: Array<RequestInit> = [];
|
||||||
globalThis.fetch = ((url: string, init: RequestInit) => {
|
globalThis.fetch = ((_url: string, init: RequestInit) => {
|
||||||
calls.push(init);
|
calls.push(init);
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
sseResponse([JSON.stringify({ choices: [{ delta: { content: "ok" } }] })]),
|
sseResponse([JSON.stringify({ choices: [{ delta: { content: "ok" } }] })]),
|
||||||
|
|||||||
@@ -143,11 +143,15 @@ export class AgentController {
|
|||||||
extraBody: { ...opts.extraBody, ...thinking },
|
extraBody: { ...opts.extraBody, ...thinking },
|
||||||
})) {
|
})) {
|
||||||
if (chunk.type === "text") {
|
if (chunk.type === "text") {
|
||||||
cb.onText?.(chunk.text);
|
if (chunk.text !== undefined) {
|
||||||
buffered.push(chunk);
|
cb.onText?.(chunk.text);
|
||||||
|
buffered.push(chunk);
|
||||||
|
}
|
||||||
} else if (chunk.type === "reasoning") {
|
} else if (chunk.type === "reasoning") {
|
||||||
cb.onReasoning?.(chunk.reasoning);
|
if (chunk.reasoning !== undefined) {
|
||||||
buffered.push(chunk);
|
cb.onReasoning?.(chunk.reasoning);
|
||||||
|
buffered.push(chunk);
|
||||||
|
}
|
||||||
} else if (chunk.type === "done") {
|
} else if (chunk.type === "done") {
|
||||||
if (chunk.usage) {
|
if (chunk.usage) {
|
||||||
this.usage = chunk.usage;
|
this.usage = chunk.usage;
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { AIStore } from "./index.js";
|
import { AIStore } from "./index.js";
|
||||||
|
import type { SessionRow } from "./index.js";
|
||||||
import type { AISessionPersistence } from "./index.js";
|
import type { AISessionPersistence } from "./index.js";
|
||||||
|
|
||||||
/** A simple in-memory persistence backend for tests. */
|
/** A simple in-memory persistence backend for tests. */
|
||||||
function memoryPersistence(): AISessionPersistence {
|
function memoryPersistence(): AISessionPersistence {
|
||||||
const rows = new Map<string, unknown>();
|
const rows = new Map<string, SessionRow>();
|
||||||
return {
|
return {
|
||||||
list: async (projectId) =>
|
list: async (projectId) =>
|
||||||
Array.from(rows.values())
|
Array.from(rows.values())
|
||||||
.filter((r) => r.projectId === projectId)
|
.filter((r) => r.projectId === projectId)
|
||||||
.map((r) => ({ ...r })),
|
.map((r) => ({ ...r })),
|
||||||
get: async (id) => (rows.has(id) ? { ...rows.get(id) } : null),
|
get: async (id) => (rows.has(id) ? ({ ...rows.get(id) } as SessionRow) : null),
|
||||||
save: async (session) => {
|
save: async (session) => {
|
||||||
rows.set(session.id, { ...session });
|
rows.set(session.id, { ...session });
|
||||||
},
|
},
|
||||||
@@ -62,10 +63,8 @@ describe("AIStore", () => {
|
|||||||
it("adds a message to a session", async () => {
|
it("adds a message to a session", async () => {
|
||||||
const session = await store.createSession();
|
const session = await store.createSession();
|
||||||
const updated = await store.addMessage(session.id, {
|
const updated = await store.addMessage(session.id, {
|
||||||
id: "m1",
|
|
||||||
role: "user",
|
role: "user",
|
||||||
content: "hello",
|
content: "hello",
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
});
|
});
|
||||||
expect(updated.messages).toHaveLength(1);
|
expect(updated.messages).toHaveLength(1);
|
||||||
expect(updated.messages[0]?.role).toBe("user");
|
expect(updated.messages[0]?.role).toBe("user");
|
||||||
@@ -74,14 +73,12 @@ describe("AIStore", () => {
|
|||||||
it("adds a suggestion and lists it", async () => {
|
it("adds a suggestion and lists it", async () => {
|
||||||
const session = await store.createSession();
|
const session = await store.createSession();
|
||||||
const updated = await store.addSuggestion(session.id, {
|
const updated = await store.addSuggestion(session.id, {
|
||||||
id: "s1",
|
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
kind: "brainstorm",
|
kind: "brainstorm",
|
||||||
title: "Ideas",
|
title: "Ideas",
|
||||||
text: "some ideas",
|
text: "some ideas",
|
||||||
summary: "summary",
|
summary: "summary",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
});
|
});
|
||||||
expect(updated.suggestions).toHaveLength(1);
|
expect(updated.suggestions).toHaveLength(1);
|
||||||
const list = await store.listSuggestions(session.id);
|
const list = await store.listSuggestions(session.id);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import type {
|
|||||||
} from "@pubscript/core";
|
} from "@pubscript/core";
|
||||||
|
|
||||||
/** A persisted session row. */
|
/** A persisted session row. */
|
||||||
interface SessionRow {
|
export interface SessionRow {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -76,7 +76,8 @@ export function createIndexedDBPersistence(dbName = "pubscript"): AISessionPersi
|
|||||||
return table.where("projectId").equals(projectId).sortBy("updatedAt").then((rows) => rows.reverse());
|
return table.where("projectId").equals(projectId).sortBy("updatedAt").then((rows) => rows.reverse());
|
||||||
},
|
},
|
||||||
async get(id: string) {
|
async get(id: string) {
|
||||||
return table.get(id) ?? null;
|
const row = await table.get(id);
|
||||||
|
return row ?? null;
|
||||||
},
|
},
|
||||||
async save(row: SessionRow) {
|
async save(row: SessionRow) {
|
||||||
await table.put(row);
|
await table.put(row);
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ describe("NoteBuilder", () => {
|
|||||||
|
|
||||||
const init = calls[0] as RequestInit;
|
const init = calls[0] as RequestInit;
|
||||||
const body = JSON.parse(init.body as string) as Record<string, unknown>;
|
const body = JSON.parse(init.body as string) as Record<string, unknown>;
|
||||||
const messages = body.messages as Array<{ content: string }>;
|
const messages = body.messages as Array<{ content: string; role?: string }>;
|
||||||
const system = messages.find((m) => m.role === "system")?.content ?? "";
|
const system = messages.find((m) => m.role === "system")?.content ?? "";
|
||||||
expect(system).toContain("The Stolen Relic");
|
expect(system).toContain("The Stolen Relic");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export class NoteBuilder {
|
|||||||
|
|
||||||
// Extract tags from #word patterns and a leading "tags:" line.
|
// Extract tags from #word patterns and a leading "tags:" line.
|
||||||
const tagMatches = [...cleaned.matchAll(/#([\p{L}[\]_][\p{L}[\]0-9 ]{1,20})/gu)];
|
const tagMatches = [...cleaned.matchAll(/#([\p{L}[\]_][\p{L}[\]0-9 ]{1,20})/gu)];
|
||||||
const tags = [...new Set(tagMatches.map((m) => m[1].trim().toLowerCase()))].slice(0, 8);
|
const tags = [...new Set(tagMatches.map((m) => m[1]!.trim().toLowerCase()))].slice(0, 8);
|
||||||
|
|
||||||
// Drop the title line and any tags line from the body.
|
// Drop the title line and any tags line from the body.
|
||||||
const body = lines
|
const body = lines
|
||||||
|
|||||||
@@ -78,8 +78,6 @@ describe("SuggestionMutation", () => {
|
|||||||
const mut = new SuggestionMutation(suggestion({
|
const mut = new SuggestionMutation(suggestion({
|
||||||
originalText: "The cat sat",
|
originalText: "The cat sat",
|
||||||
newText: "The dog barked",
|
newText: "The dog barked",
|
||||||
anchorIndex: undefined,
|
|
||||||
spanLength: undefined,
|
|
||||||
}));
|
}));
|
||||||
const result = mut.apply(doc);
|
const result = mut.apply(doc);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
@@ -91,8 +89,6 @@ describe("SuggestionMutation", () => {
|
|||||||
const mut = new SuggestionMutation(suggestion({
|
const mut = new SuggestionMutation(suggestion({
|
||||||
originalText: "cat",
|
originalText: "cat",
|
||||||
newText: "dog",
|
newText: "dog",
|
||||||
anchorIndex: undefined,
|
|
||||||
spanLength: 3,
|
|
||||||
}));
|
}));
|
||||||
const result = mut.apply(doc);
|
const result = mut.apply(doc);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ describe("SuggestionBuilder", () => {
|
|||||||
|
|
||||||
const init = calls[0] as RequestInit;
|
const init = calls[0] as RequestInit;
|
||||||
const body = JSON.parse(init.body as string) as Record<string, unknown>;
|
const body = JSON.parse(init.body as string) as Record<string, unknown>;
|
||||||
const messages = body.messages as Array<{ content: string }>;
|
const messages = body.messages as Array<{ content: string; role?: string }>;
|
||||||
const system = messages.find((m) => m.role === "system")?.content ?? "";
|
const system = messages.find((m) => m.role === "system")?.content ?? "";
|
||||||
expect(system).toContain("The Return");
|
expect(system).toContain("The Return");
|
||||||
expect(system).toContain("the capital");
|
expect(system).toContain("the capital");
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export class SuggestionBuilder {
|
|||||||
const cleaned = text.trim();
|
const cleaned = text.trim();
|
||||||
const firstLine = cleaned.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
|
const firstLine = cleaned.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
|
||||||
const title = firstLine.replace(/^#+\s*/, "").trim().slice(0, 120) || `${kind} suggestion`;
|
const title = firstLine.replace(/^#+\s*/, "").trim().slice(0, 120) || `${kind} suggestion`;
|
||||||
const summary = cleaned.split(/\r?\n\r?\n/)[0].replace(/^#+\s*/, "").trim().slice(0, 300);
|
const summary = cleaned.split(/\r?\n\r?\n/)[0]!.replace(/^#+\s*/, "").trim().slice(0, 300);
|
||||||
return { title, summary, text: cleaned };
|
return { title, summary, text: cleaned };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,11 +200,14 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
|||||||
async saveAISession(s: AISession): Promise<void> {
|
async saveAISession(s: AISession): Promise<void> {
|
||||||
await req("POST", `/api/projects/${s.projectId}/ai/sessions`, {
|
await req("POST", `/api/projects/${s.projectId}/ai/sessions`, {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
role: s.role,
|
title: s.title,
|
||||||
model: s.model,
|
kind: s.kind,
|
||||||
settings: s.settings,
|
|
||||||
messages: s.messages,
|
messages: s.messages,
|
||||||
suggestions: s.suggestions,
|
suggestions: s.suggestions,
|
||||||
|
notes: s.notes,
|
||||||
|
createdAt: s.createdAt,
|
||||||
|
updatedAt: s.updatedAt,
|
||||||
|
lastMessageAt: s.lastMessageAt,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ function mockServerFetch(): typeof fetch {
|
|||||||
}
|
}
|
||||||
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/ai\/sessions$/);
|
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/ai\/sessions$/);
|
||||||
if (method === "POST" && m) {
|
if (method === "POST" && m) {
|
||||||
const s = { id: body.id ?? id(), projectId: m[1]!, role: body.role, model: body.model, settings: body.settings ?? {}, messages: body.messages ?? [], suggestions: body.suggestions ?? [] };
|
const now = new Date().toISOString();
|
||||||
|
const s = { id: body.id ?? id(), projectId: m[1]!, title: body.title ?? "", kind: body.kind ?? "session", messages: body.messages ?? [], suggestions: body.suggestions ?? [], notes: body.notes ?? [], createdAt: body.createdAt ?? now, updatedAt: body.updatedAt ?? now, lastMessageAt: body.lastMessageAt ?? null };
|
||||||
sessions.set(s.id, s);
|
sessions.set(s.id, s);
|
||||||
return json({ session: s });
|
return json({ session: s });
|
||||||
}
|
}
|
||||||
@@ -131,7 +132,8 @@ async function runScenario(adapter: StorageAdapter) {
|
|||||||
edges: [],
|
edges: [],
|
||||||
});
|
});
|
||||||
await adapter.saveAISession({
|
await adapter.saveAISession({
|
||||||
id: "sess-1", projectId: project.id, role: "editor", model: "m", settings: {}, messages: [], suggestions: [],
|
id: "sess-1", projectId: project.id, title: "Session", kind: "session",
|
||||||
|
messages: [], suggestions: [], notes: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), lastMessageAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsub = adapter.onEvent(() => {});
|
const unsub = adapter.onEvent(() => {});
|
||||||
|
|||||||
@@ -211,13 +211,23 @@ export interface Note {
|
|||||||
linkedTo?: string;
|
linkedTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SuggestionKind =
|
/** Editor inline-review suggestion kinds. */
|
||||||
|
export type EditorSuggestionKind =
|
||||||
| "replace"
|
| "replace"
|
||||||
| "insert"
|
| "insert"
|
||||||
| "delete"
|
| "delete"
|
||||||
| "entity-update"
|
| "entity-update"
|
||||||
| "draft";
|
| "draft";
|
||||||
|
|
||||||
|
/** AI suggestion kinds produced by the AI provider layer. */
|
||||||
|
export type SuggestionKind =
|
||||||
|
| "brainstorm"
|
||||||
|
| "outline"
|
||||||
|
| "research"
|
||||||
|
| "review"
|
||||||
|
| "expand"
|
||||||
|
| "summarize";
|
||||||
|
|
||||||
export type SuggestionStatus = "pending" | "accepted" | "rejected" | "pinned";
|
export type SuggestionStatus = "pending" | "accepted" | "rejected" | "pinned";
|
||||||
|
|
||||||
export interface SuggestionTarget {
|
export interface SuggestionTarget {
|
||||||
@@ -234,20 +244,81 @@ export interface Suggestion {
|
|||||||
before: string;
|
before: string;
|
||||||
after: string;
|
after: string;
|
||||||
rationale: string;
|
rationale: string;
|
||||||
kind: SuggestionKind;
|
kind: EditorSuggestionKind;
|
||||||
status: SuggestionStatus;
|
status: SuggestionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AISessionRole = "editor" | "reader" | "publisher" | "co-author";
|
/** Kinds of AI-backed sessions. */
|
||||||
|
export type SessionKind = "session" | "agent";
|
||||||
|
|
||||||
|
/** Role of a message within an AI session. */
|
||||||
|
export type AIMessageRole = "system" | "user" | "assistant";
|
||||||
|
|
||||||
|
/** A single message in an AI session. */
|
||||||
|
export interface AIMessage {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
role: AIMessageRole;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kind of an AI-generated note. */
|
||||||
|
export type NoteKind = "character" | "world" | "plot" | "theme" | "general";
|
||||||
|
|
||||||
|
/** Lifecycle status of an AI-generated note. */
|
||||||
|
export type NoteStatus = "draft" | "published";
|
||||||
|
|
||||||
|
/** An AI-generated note attached to a session. */
|
||||||
|
export interface AINote {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
kind: NoteKind;
|
||||||
|
title?: string;
|
||||||
|
body: string;
|
||||||
|
tags?: string[];
|
||||||
|
status: NoteStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lifecycle status of an AI-generated suggestion. */
|
||||||
|
export type AISuggestionStatus =
|
||||||
|
| "pending"
|
||||||
|
| "applied"
|
||||||
|
| "accepted"
|
||||||
|
| "rejected";
|
||||||
|
|
||||||
|
/** An AI-generated suggestion attached to a session. */
|
||||||
|
export interface AISuggestion {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
createdAt: string;
|
||||||
|
kind: SuggestionKind;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
summary: string;
|
||||||
|
originalText?: string;
|
||||||
|
newText?: string;
|
||||||
|
anchorIndex?: number;
|
||||||
|
spanLength?: number;
|
||||||
|
status: AISuggestionStatus;
|
||||||
|
appliedAt?: string;
|
||||||
|
resolvedAt?: string;
|
||||||
|
resolution?: "accepted" | "rejected";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An AI-backed session with its messages, suggestions, and notes. */
|
||||||
export interface AISession {
|
export interface AISession {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
role: AISessionRole;
|
title: string;
|
||||||
model: string;
|
kind: SessionKind;
|
||||||
settings: Record<string, unknown>;
|
messages: AIMessage[];
|
||||||
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
|
suggestions: AISuggestion[];
|
||||||
suggestions: Suggestion[];
|
notes: AINote[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
lastMessageAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A reference to a document that can be saved through the adapter. */
|
/** A reference to a document that can be saved through the adapter. */
|
||||||
|
|||||||
+12
-7
@@ -1,6 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
AISession,
|
AISession,
|
||||||
AISessionRole,
|
|
||||||
AuditLogEntry,
|
AuditLogEntry,
|
||||||
Board,
|
Board,
|
||||||
Book,
|
Book,
|
||||||
@@ -11,6 +10,7 @@ import type {
|
|||||||
PlotBeat,
|
PlotBeat,
|
||||||
Project,
|
Project,
|
||||||
ProjectMeta,
|
ProjectMeta,
|
||||||
|
SessionKind,
|
||||||
User,
|
User,
|
||||||
WorldItem,
|
WorldItem,
|
||||||
} from "@pubscript/core";
|
} from "@pubscript/core";
|
||||||
@@ -98,12 +98,14 @@ export interface PlotBeatRow {
|
|||||||
export interface AISessionRow {
|
export interface AISessionRow {
|
||||||
id: string;
|
id: string;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
role: string;
|
title: string;
|
||||||
model: string;
|
kind: string;
|
||||||
settings: string;
|
|
||||||
messages: string;
|
messages: string;
|
||||||
suggestions: string;
|
suggestions: string;
|
||||||
|
notes: string;
|
||||||
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
last_message_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Row -> core types ----
|
// ---- Row -> core types ----
|
||||||
@@ -235,11 +237,14 @@ export function toAISession(r: AISessionRow): AISession {
|
|||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
projectId: r.project_id,
|
projectId: r.project_id,
|
||||||
role: r.role as AISessionRole,
|
title: r.title,
|
||||||
model: r.model,
|
kind: r.kind as SessionKind,
|
||||||
settings: JSON.parse(r.settings),
|
|
||||||
messages: JSON.parse(r.messages),
|
messages: JSON.parse(r.messages),
|
||||||
suggestions: JSON.parse(r.suggestions),
|
suggestions: JSON.parse(r.suggestions),
|
||||||
|
notes: JSON.parse(r.notes),
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
lastMessageAt: r.last_message_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Chunk #9: reshape ai_sessions to the full AISession shape.
|
||||||
|
-- Adds title, kind, notes, created_at, last_message_at; drops role, model, settings.
|
||||||
|
CREATE TABLE ai_sessions_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
kind TEXT NOT NULL DEFAULT 'session',
|
||||||
|
messages TEXT NOT NULL DEFAULT '[]',
|
||||||
|
suggestions TEXT NOT NULL DEFAULT '[]',
|
||||||
|
notes TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
last_message_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO ai_sessions_new (id, project_id, title, kind, messages, suggestions, notes, created_at, updated_at, last_message_at)
|
||||||
|
SELECT id, project_id, '', 'session', messages, suggestions, '[]', updated_at, updated_at, NULL
|
||||||
|
FROM ai_sessions;
|
||||||
|
|
||||||
|
DROP TABLE ai_sessions;
|
||||||
|
|
||||||
|
ALTER TABLE ai_sessions_new RENAME TO ai_sessions;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_sessions_project ON ai_sessions(project_id);
|
||||||
+35
-17
@@ -92,11 +92,14 @@ const beatCreate = z.object({
|
|||||||
});
|
});
|
||||||
const aiSessionCreate = z.object({
|
const aiSessionCreate = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
role: z.string().min(1),
|
title: z.string().default(""),
|
||||||
model: z.string().min(1),
|
kind: z.enum(["session", "agent"]).default("session"),
|
||||||
settings: z.record(z.unknown()).default({}),
|
messages: z.array(z.any()).default([]),
|
||||||
messages: z.array(z.any()).optional(),
|
suggestions: z.array(z.any()).default([]),
|
||||||
suggestions: z.array(z.any()).optional(),
|
notes: z.array(z.any()).default([]),
|
||||||
|
createdAt: z.string().optional(),
|
||||||
|
updatedAt: z.string().optional(),
|
||||||
|
lastMessageAt: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
const aiMessageAppend = z.object({ role: z.string(), content: z.string() });
|
const aiMessageAppend = z.object({ role: z.string(), content: z.string() });
|
||||||
const aiSuggestionAdd = z.object({ kind: z.string(), text: z.string() });
|
const aiSuggestionAdd = z.object({ kind: z.string(), text: z.string() });
|
||||||
@@ -565,14 +568,15 @@ export function registerRoutes(app: FastifyInstance): void {
|
|||||||
const sid = body.id ?? ulid();
|
const sid = body.id ?? ulid();
|
||||||
const t = now();
|
const t = now();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO ai_sessions (id, project_id, role, model, settings, messages, suggestions, updated_at)
|
`INSERT INTO ai_sessions (id, project_id, title, kind, messages, suggestions, notes, created_at, updated_at, last_message_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
project_id = excluded.project_id, role = excluded.role, model = excluded.model,
|
title = excluded.title, kind = excluded.kind,
|
||||||
settings = excluded.settings, messages = excluded.messages, suggestions = excluded.suggestions,
|
messages = excluded.messages, suggestions = excluded.suggestions, notes = excluded.notes,
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = excluded.updated_at, last_message_at = excluded.last_message_at`,
|
||||||
).run(sid, id, body.role, body.model, JSON.stringify(body.settings),
|
).run(sid, id, body.title, body.kind, JSON.stringify(body.messages),
|
||||||
JSON.stringify(body.messages ?? []), JSON.stringify(body.suggestions ?? []), t);
|
JSON.stringify(body.suggestions), JSON.stringify(body.notes),
|
||||||
|
body.createdAt ?? t, t, body.lastMessageAt ?? null);
|
||||||
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(sid) as AISessionRow;
|
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(sid) as AISessionRow;
|
||||||
return { session: toAISession(row) };
|
return { session: toAISession(row) };
|
||||||
});
|
});
|
||||||
@@ -590,11 +594,25 @@ export function registerRoutes(app: FastifyInstance): void {
|
|||||||
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined;
|
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined;
|
||||||
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
|
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
|
||||||
requireProjectAccess(app, req, row.project_id, true);
|
requireProjectAccess(app, req, row.project_id, true);
|
||||||
const body = z.object({ settings: z.record(z.unknown()).optional(), model: z.string().optional() }).parse(req.body);
|
const body = z.object({
|
||||||
const settings = body.settings ? JSON.stringify(body.settings) : row.settings;
|
title: z.string().optional(),
|
||||||
const model = body.model ?? row.model;
|
kind: z.enum(["session", "agent"]).optional(),
|
||||||
|
messages: z.array(z.any()).optional(),
|
||||||
|
suggestions: z.array(z.any()).optional(),
|
||||||
|
notes: z.array(z.any()).optional(),
|
||||||
|
lastMessageAt: z.string().nullable().optional(),
|
||||||
|
}).parse(req.body);
|
||||||
const t = now();
|
const t = now();
|
||||||
db.prepare("UPDATE ai_sessions SET settings = ?, model = ?, updated_at = ? WHERE id = ?").run(settings, model, t, id);
|
db.prepare(
|
||||||
|
`UPDATE ai_sessions SET title = ?, kind = ?, messages = ?, suggestions = ?, notes = ?, last_message_at = ?, updated_at = ? WHERE id = ?`
|
||||||
|
).run(
|
||||||
|
body.title ?? row.title, body.kind ?? row.kind,
|
||||||
|
JSON.stringify(body.messages ?? JSON.parse(row.messages)),
|
||||||
|
JSON.stringify(body.suggestions ?? JSON.parse(row.suggestions)),
|
||||||
|
JSON.stringify(body.notes ?? JSON.parse(row.notes)),
|
||||||
|
body.lastMessageAt ?? row.last_message_at,
|
||||||
|
t, id,
|
||||||
|
);
|
||||||
const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow;
|
const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow;
|
||||||
return { session: toAISession(updated) };
|
return { session: toAISession(updated) };
|
||||||
});
|
});
|
||||||
@@ -617,7 +635,7 @@ export function registerRoutes(app: FastifyInstance): void {
|
|||||||
const messages = JSON.parse(row.messages) as any[];
|
const messages = JSON.parse(row.messages) as any[];
|
||||||
messages.push({ role, content, at: now() });
|
messages.push({ role, content, at: now() });
|
||||||
const t = now();
|
const t = now();
|
||||||
db.prepare("UPDATE ai_sessions SET messages = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(messages), t, id);
|
db.prepare("UPDATE ai_sessions SET messages = ?, last_message_at = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(messages), t, t, id);
|
||||||
const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow;
|
const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow;
|
||||||
return { session: toAISession(updated) };
|
return { session: toAISession(updated) };
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user