feat: add scoped history and artifacts
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { registerChatTopicRoutes } from "./chat-topic-routes.js";
|
||||
import { matchHistoryText } from "../sessions/history-search.js";
|
||||
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
|
||||
import { type Router } from "express";
|
||||
import { z } from "zod";
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
} from "../sessions/metadataStore.js";
|
||||
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
||||
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
||||
import { HistoryCursorError, historyScope, sessionHistoryPage, sessionMessagePage } from "../sessions/history-page.js";
|
||||
import { type LearningOrchestrator } from "../learning/orchestrator.js";
|
||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||
import { type ChatRunState } from "./chatRunState.js";
|
||||
@@ -38,6 +41,7 @@ const createSessionPayloadSchema = z.object({
|
||||
session_id: z.string().max(128).optional(),
|
||||
parent_session_id: z.string().max(128).optional(),
|
||||
trigger_context: agentTriggerContextSchema.optional(),
|
||||
topic_id: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
const taskExecutionPayloadSchema = z.object({
|
||||
@@ -50,6 +54,19 @@ const taskExecutionPayloadSchema = z.object({
|
||||
const sessionListQuerySchema = z.object({
|
||||
purpose: z.enum(["ordinary_chat", "task_execution", "task_collaboration"]).optional(),
|
||||
task_id: z.string().uuid().optional(),
|
||||
include_collaboration: z.enum(["true", "false"]).optional(),
|
||||
q: z.string().trim().max(200).optional(),
|
||||
topic_id: z.union([z.string().uuid(), z.literal("unclassified")]).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).optional(),
|
||||
cursor: z.string().max(2048).optional(),
|
||||
});
|
||||
|
||||
const messageQuerySchema = z.object({
|
||||
message_limit: z.coerce.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
const olderMessagesQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(40),
|
||||
before: z.string().max(2048).optional(),
|
||||
});
|
||||
|
||||
const forkPayloadSchema = z.object({
|
||||
@@ -91,6 +108,7 @@ export const registerChatSessionRoutes = (
|
||||
sessionTranscriptStore,
|
||||
sessionUiStateStore,
|
||||
} = options;
|
||||
registerChatTopicRoutes(chatRouter, sessionMetadataStore);
|
||||
const { activeRuns, lastRunStatuses } = runState;
|
||||
const getSessionRunStatus = runState.getStatus;
|
||||
|
||||
@@ -191,6 +209,9 @@ export const registerChatSessionRoutes = (
|
||||
const userId = authContext.userId;
|
||||
const actorKey = authContext.actorKey;
|
||||
const projectKey = authContext.projectKey;
|
||||
if (parsed.data.topic_id && !await sessionMetadataStore.topics.get(authContext, parsed.data.topic_id)) {
|
||||
res.status(404).json({ message: "主题不存在" }); return;
|
||||
}
|
||||
const trigger = parsed.data.trigger_context;
|
||||
if (trigger && !projectId) {
|
||||
res.status(403).json({ code: "PROJECT_CONTEXT_REQUIRED", message: "任务会话必须关联项目" });
|
||||
@@ -244,6 +265,7 @@ export const registerChatSessionRoutes = (
|
||||
const { record, created } = await sessionMetadataStore.ensure({
|
||||
actorKey,
|
||||
parentSessionId: parsed.data.parent_session_id,
|
||||
topicId: parsed.data.topic_id,
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId,
|
||||
@@ -267,6 +289,7 @@ export const registerChatSessionRoutes = (
|
||||
status: record.status,
|
||||
title: record.title,
|
||||
parent_session_id: record.parentSessionId,
|
||||
topic_id: record.topicId ?? null,
|
||||
created,
|
||||
event_duplicate: false,
|
||||
trigger_id: record.triggerId,
|
||||
@@ -460,19 +483,52 @@ export const registerChatSessionRoutes = (
|
||||
const userId = authContext.userId;
|
||||
const actorKey = authContext.actorKey;
|
||||
const projectKey = authContext.projectKey;
|
||||
const records = await sessionMetadataStore.list({
|
||||
const includeCollaboration = query.data.include_collaboration === "true";
|
||||
let records = await sessionMetadataStore.list({
|
||||
actorKey,
|
||||
projectId,
|
||||
projectKey,
|
||||
userId,
|
||||
}, {
|
||||
purpose: query.data.purpose ?? "ordinary_chat",
|
||||
purpose: includeCollaboration ? undefined : query.data.purpose ?? "ordinary_chat",
|
||||
taskId: query.data.task_id,
|
||||
});
|
||||
if (includeCollaboration) records = records.filter((record) => ["ordinary_chat", "task_collaboration"].includes(readPurpose(record)));
|
||||
const topics = new Map((await sessionMetadataStore.topics.list(authContext)).map((topic) => [topic.id, topic]));
|
||||
const topicId = query.data.topic_id;
|
||||
if (topicId && topicId !== "unclassified" && !topics.has(topicId)) {
|
||||
res.status(404).json({ message: "主题不存在" }); return;
|
||||
}
|
||||
records = records.filter((record) => !topicId || (topicId === "unclassified"
|
||||
? !record.topicId || !topics.has(record.topicId) : record.topicId === topicId));
|
||||
const searchable: Array<SessionRecord & { searchSnippet?: string }> = [];
|
||||
for (const record of records) {
|
||||
const titleMatches = (record.title ?? "新对话").toLocaleLowerCase().includes((query.data.q ?? "").toLocaleLowerCase());
|
||||
const state = query.data.q && !titleMatches ? await sessionUiStateStore.read(toSessionUiStateContext(record)) : null;
|
||||
const messages = activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? [];
|
||||
searchable.push({ ...record, searchSnippet: query.data.q && !titleMatches ? matchHistoryText(messages, query.data.q) : undefined });
|
||||
}
|
||||
let page;
|
||||
try {
|
||||
page = sessionHistoryPage(searchable, {
|
||||
scope: historyScope([actorKey, projectKey, userId, query.data.purpose ?? "ordinary_chat", query.data.task_id, topicId, includeCollaboration]),
|
||||
q: query.data.q, limit: query.data.limit ?? (query.data.q || query.data.cursor ? 30 : Math.max(records.length, 1)),
|
||||
cursor: query.data.cursor,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof HistoryCursorError)) throw error;
|
||||
res.status(400).json({ message: error.message });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
sessions: records.map((record) => ({
|
||||
has_more: page.has_more,
|
||||
next_cursor: page.next_cursor,
|
||||
sessions: page.sessions.map((record) => ({
|
||||
id: record.sessionId,
|
||||
title: record.title ?? "新对话",
|
||||
topic_id: record.topicId && topics.has(record.topicId) ? record.topicId : null,
|
||||
topic_name: record.topicId ? topics.get(record.topicId)?.name : undefined,
|
||||
search_snippet: record.searchSnippet,
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
status: record.status,
|
||||
@@ -490,7 +546,27 @@ export const registerChatSessionRoutes = (
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.get("/session/:session_id/messages", async (req, res) => {
|
||||
const query = olderMessagesQuerySchema.safeParse(req.query);
|
||||
if (!query.success) { res.status(400).json({ message: "invalid message pagination" }); return; }
|
||||
const context = getLocalAgentContext(req);
|
||||
const record = await sessionMetadataStore.get(context, req.params.session_id);
|
||||
if (!record) { res.status(404).json({ message: "session not found" }); return; }
|
||||
const state = await sessionUiStateStore.read(toSessionUiStateContext(record));
|
||||
const messages = readPurpose(record) === "task_collaboration" ? state?.messages ?? []
|
||||
: activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? [];
|
||||
try {
|
||||
res.json(sessionMessagePage(messages, { scope: historyScope([record.actorKey, record.projectKey, record.sessionId]),
|
||||
...query.data }));
|
||||
} catch (error) {
|
||||
if (!(error instanceof HistoryCursorError)) throw error;
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
chatRouter.get("/session/:session_id", async (req, res) => {
|
||||
const query = messageQuerySchema.safeParse(req.query);
|
||||
if (!query.success) { res.status(400).json({ message: "invalid message pagination" }); return; }
|
||||
const sessionId = req.params.session_id?.trim();
|
||||
const authContext = getLocalAgentContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
@@ -519,15 +595,21 @@ export const registerChatSessionRoutes = (
|
||||
const state = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(sessionRecord),
|
||||
);
|
||||
const messages = readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? []
|
||||
: activeRuns.get(sessionRecord.sessionId)?.messages ?? state?.messages ?? [];
|
||||
const messagePayload = query.data.message_limit ? sessionMessagePage(messages, {
|
||||
scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit,
|
||||
}) : { messages };
|
||||
res.json({
|
||||
id: sessionRecord.sessionId,
|
||||
title: sessionRecord.title ?? "新对话",
|
||||
topic_id: sessionRecord.topicId && await sessionMetadataStore.topics.get(authContext, sessionRecord.topicId) ? sessionRecord.topicId : null,
|
||||
is_title_manually_edited: state?.isTitleManuallyEdited ?? false,
|
||||
created_at: sessionRecord.createdAt,
|
||||
updated_at: sessionRecord.updatedAt,
|
||||
status: sessionRecord.status,
|
||||
session_id: sessionRecord.sessionId,
|
||||
messages: state?.messages ?? [],
|
||||
...messagePayload,
|
||||
execution_plans: state?.executionPlans ?? [],
|
||||
parent_session_id: sessionRecord.parentSessionId,
|
||||
is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running",
|
||||
@@ -538,6 +620,8 @@ export const registerChatSessionRoutes = (
|
||||
});
|
||||
|
||||
chatRouter.get("/session/:session_id/stream", async (req, res) => {
|
||||
const query = messageQuerySchema.safeParse(req.query);
|
||||
if (!query.success) { res.status(400).json({ message: "invalid message pagination" }); return; }
|
||||
const sessionId = req.params.session_id?.trim();
|
||||
const authContext = getLocalAgentContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
@@ -567,12 +651,15 @@ export const registerChatSessionRoutes = (
|
||||
|
||||
const run = activeRuns.get(sessionRecord.sessionId);
|
||||
const state = await sessionUiStateStore.read(toSessionUiStateContext(sessionRecord));
|
||||
const messages = readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? []
|
||||
: run?.messages ?? state?.messages ?? [];
|
||||
const messagePayload = query.data.message_limit ? sessionMessagePage(messages, {
|
||||
scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit,
|
||||
}) : { messages };
|
||||
res.write(
|
||||
toSse("state", {
|
||||
session_id: sessionRecord.sessionId,
|
||||
messages: readPurpose(sessionRecord) === "task_collaboration"
|
||||
? state?.messages ?? []
|
||||
: run?.messages ?? state?.messages ?? [],
|
||||
...messagePayload,
|
||||
execution_plans: run ? undefined : state?.executionPlans,
|
||||
is_streaming: run?.status === "running",
|
||||
run_status: getSessionRunStatus(sessionRecord.sessionId),
|
||||
@@ -740,6 +827,7 @@ export const registerChatSessionRoutes = (
|
||||
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
|
||||
actorKey,
|
||||
parentSessionId: sourceSessionId,
|
||||
topicId: sourceSessionRecord?.topicId ?? undefined,
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId: forkSession.id,
|
||||
|
||||
Reference in New Issue
Block a user