mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2026-01-01 20:54:23 +01:00
feat: implmenet core apis
This commit is contained in:
10
src/lib/conversation-schema/content/ImageContentSchema.ts
Normal file
10
src/lib/conversation-schema/content/ImageContentSchema.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const ImageContentSchema = z.object({
|
||||
type: z.literal('image'),
|
||||
source: z.object({
|
||||
type: z.literal('base64'),
|
||||
data: z.string(),
|
||||
media_type: z.enum(['image/png']),
|
||||
}),
|
||||
}).strict()
|
||||
6
src/lib/conversation-schema/content/TextContentSchema.ts
Normal file
6
src/lib/conversation-schema/content/TextContentSchema.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const TextContentSchema = z.object({
|
||||
type: z.literal('text'),
|
||||
text: z.string(),
|
||||
}).strict()
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const ThinkingContentSchema = z.object({
|
||||
type: z.literal('thinking'),
|
||||
thinking: z.string(),
|
||||
signature: z.string().optional(),
|
||||
}).strict()
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
import { TextContentSchema } from './TextContentSchema'
|
||||
import { ImageContentSchema } from './ImageContentSchema'
|
||||
|
||||
export const ToolResultContentSchema = z.object({
|
||||
type: z.literal('tool_result'),
|
||||
tool_use_id: z.string(),
|
||||
content: z.union([z.string(), z.array(z.union([
|
||||
TextContentSchema,
|
||||
ImageContentSchema
|
||||
]))]),
|
||||
is_error: z.boolean().optional(),
|
||||
}).strict()
|
||||
|
||||
|
||||
export type ToolResultContent = z.infer<typeof ToolResultContentSchema>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const ToolUseContentSchema = z.object({
|
||||
type: z.literal('tool_use'),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
input: z.record(z.string(), z.unknown()),
|
||||
}).strict()
|
||||
15
src/lib/conversation-schema/entry/AssistantEntrySchema.ts
Normal file
15
src/lib/conversation-schema/entry/AssistantEntrySchema.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod'
|
||||
import { AssistantMessageSchema } from '../message/AssistantMessageSchema'
|
||||
import { BaseEntrySchema } from './BaseEntrySchema'
|
||||
|
||||
export const AssistantEntrySchema = BaseEntrySchema.extend({
|
||||
// discriminator
|
||||
type: z.literal('assistant'),
|
||||
|
||||
// required
|
||||
message: AssistantMessageSchema,
|
||||
|
||||
// optional
|
||||
requestId: z.string().optional(),
|
||||
isApiErrorMessage: z.boolean().optional(),
|
||||
}).strict()
|
||||
21
src/lib/conversation-schema/entry/BaseEntrySchema.ts
Normal file
21
src/lib/conversation-schema/entry/BaseEntrySchema.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const BaseEntrySchema = z.object({
|
||||
// required
|
||||
isSidechain: z.boolean(),
|
||||
userType: z.enum(['external']),
|
||||
cwd: z.string(),
|
||||
sessionId: z.string(),
|
||||
version: z.string(),
|
||||
uuid: z.uuid(),
|
||||
timestamp: z.string(),
|
||||
|
||||
// nullable
|
||||
parentUuid: z.uuid().nullable(),
|
||||
|
||||
// optional
|
||||
isMeta: z.boolean().optional(),
|
||||
toolUseResult: z.unknown().optional(), // スキーマがツールごとに異なりすぎるし利用もしなそうなので unknown
|
||||
gitBranch: z.string().optional(),
|
||||
isCompactSummary: z.boolean().optional(),
|
||||
}).strict()
|
||||
7
src/lib/conversation-schema/entry/SummaryEntrySchema.ts
Normal file
7
src/lib/conversation-schema/entry/SummaryEntrySchema.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const SummaryEntrySchema = z.object({
|
||||
type: z.literal('summary'),
|
||||
summary: z.string(),
|
||||
leafUuid: z.string().uuid(),
|
||||
}).strict()
|
||||
12
src/lib/conversation-schema/entry/SystemEntrySchema.ts
Normal file
12
src/lib/conversation-schema/entry/SystemEntrySchema.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
import { BaseEntrySchema } from './BaseEntrySchema'
|
||||
|
||||
export const SystemEntrySchema = BaseEntrySchema.extend({
|
||||
// discriminator
|
||||
type: z.literal('system'),
|
||||
|
||||
// required
|
||||
content: z.string(),
|
||||
toolUseID: z.string(),
|
||||
level: z.enum(['info']),
|
||||
}).strict()
|
||||
11
src/lib/conversation-schema/entry/UserEntrySchema.ts
Normal file
11
src/lib/conversation-schema/entry/UserEntrySchema.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
import { UserMessageSchema } from '../message/UserMessageSchema'
|
||||
import { BaseEntrySchema } from './BaseEntrySchema'
|
||||
|
||||
export const UserEntrySchema = BaseEntrySchema.extend({
|
||||
// discriminator
|
||||
type: z.literal('user'),
|
||||
|
||||
// required
|
||||
message: UserMessageSchema,
|
||||
}).strict()
|
||||
14
src/lib/conversation-schema/index.ts
Normal file
14
src/lib/conversation-schema/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod'
|
||||
import { UserEntrySchema } from './entry/UserEntrySchema'
|
||||
import { AssistantEntrySchema } from './entry/AssistantEntrySchema'
|
||||
import { SummaryEntrySchema } from './entry/SummaryEntrySchema'
|
||||
import { SystemEntrySchema } from './entry/SystemEntrySchema'
|
||||
|
||||
export const ConversationSchema = z.union([
|
||||
UserEntrySchema,
|
||||
AssistantEntrySchema,
|
||||
SummaryEntrySchema,
|
||||
SystemEntrySchema,
|
||||
])
|
||||
|
||||
export type Conversation = z.infer<typeof ConversationSchema>
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from 'zod'
|
||||
import { ThinkingContentSchema } from '../content/ThinkingContentSchema'
|
||||
import { TextContentSchema } from '../content/TextContentSchema'
|
||||
import { ToolUseContentSchema } from '../content/ToolUseContentSchema'
|
||||
import { ToolResultContentSchema } from '../content/ToolResultContentSchema'
|
||||
|
||||
const AssistantMessageContentSchema = z.union([
|
||||
ThinkingContentSchema,
|
||||
TextContentSchema,
|
||||
ToolUseContentSchema,
|
||||
ToolResultContentSchema,
|
||||
])
|
||||
|
||||
export type AssistantMessageContent = z.infer<typeof AssistantMessageContentSchema>
|
||||
|
||||
export const AssistantMessageSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.literal('message'),
|
||||
role: z.literal('assistant'),
|
||||
model: z.string(),
|
||||
content: z.array(AssistantMessageContentSchema),
|
||||
stop_reason: z.string().nullable(),
|
||||
stop_sequence: z.string().nullable(),
|
||||
usage: z.object({
|
||||
input_tokens: z.number(),
|
||||
cache_creation_input_tokens: z.number().optional(),
|
||||
cache_read_input_tokens: z.number().optional(),
|
||||
cache_creation: z.object({
|
||||
ephemeral_5m_input_tokens: z.number(),
|
||||
ephemeral_1h_input_tokens: z.number(),
|
||||
}).optional(),
|
||||
output_tokens: z.number(),
|
||||
service_tier: z.string().nullable().optional(),
|
||||
server_tool_use: z.object({
|
||||
web_search_requests: z.number(),
|
||||
}).optional(),
|
||||
}),
|
||||
}).strict()
|
||||
25
src/lib/conversation-schema/message/UserMessageSchema.ts
Normal file
25
src/lib/conversation-schema/message/UserMessageSchema.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod'
|
||||
import { ToolResultContentSchema } from '../content/ToolResultContentSchema'
|
||||
import { TextContentSchema } from '../content/TextContentSchema'
|
||||
import { ImageContentSchema } from '../content/ImageContentSchema'
|
||||
|
||||
|
||||
const UserMessageContentSchema = z.union([
|
||||
z.string(),
|
||||
TextContentSchema,
|
||||
ToolResultContentSchema,
|
||||
ImageContentSchema
|
||||
])
|
||||
|
||||
export type UserMessageContent = z.infer<typeof UserMessageContentSchema>
|
||||
|
||||
export const UserMessageSchema = z.object({
|
||||
role: z.literal('user'),
|
||||
content: z.union([
|
||||
z.string(),
|
||||
z.array(z.union([
|
||||
z.string(),
|
||||
UserMessageContentSchema
|
||||
]))
|
||||
]),
|
||||
}).strict()
|
||||
59
src/lib/conversation-schema/tool/CommonToolSchema.ts
Normal file
59
src/lib/conversation-schema/tool/CommonToolSchema.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { z } from 'zod'
|
||||
import { StructuredPatchSchema } from './StructuredPatchSchema'
|
||||
|
||||
export const CommonToolResultSchema = z.union([
|
||||
z.object({
|
||||
stdout: z.string(),
|
||||
stderr: z.string(),
|
||||
interrupted: z.boolean(),
|
||||
isImage: z.boolean(),
|
||||
}).strict(),
|
||||
|
||||
// create
|
||||
z.object({
|
||||
type: z.literal('create'),
|
||||
filePath: z.string(),
|
||||
content: z.string(),
|
||||
structuredPatch: z.array(StructuredPatchSchema)
|
||||
}).strict(),
|
||||
|
||||
// update
|
||||
z.object({
|
||||
filePath: z.string(),
|
||||
oldString: z.string(),
|
||||
newString: z.string(),
|
||||
originalFile: z.string(),
|
||||
userModified: z.boolean(),
|
||||
replaceAll: z.boolean(),
|
||||
structuredPatch: z.array(StructuredPatchSchema)
|
||||
}).strict(),
|
||||
|
||||
// search?
|
||||
z.object({
|
||||
filenames: z.array(z.string()),
|
||||
durationMs: z.number(),
|
||||
numFiles: z.number(),
|
||||
truncated: z.boolean(),
|
||||
}).strict(),
|
||||
|
||||
// text
|
||||
z.object({
|
||||
type: z.literal('text'),
|
||||
file: z.object({
|
||||
filePath: z.string(),
|
||||
content: z.string(),
|
||||
numLines: z.number(),
|
||||
startLine: z.number(),
|
||||
totalLines: z.number(),
|
||||
})
|
||||
}).strict(),
|
||||
|
||||
// content
|
||||
z.object({
|
||||
mode: z.literal('content'),
|
||||
numFiles: z.number(),
|
||||
filenames: z.array(z.string()),
|
||||
content: z.string(),
|
||||
numLines: z.number(),
|
||||
}).strict(),
|
||||
])
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const StructuredPatchSchema = z.object({
|
||||
oldStart: z.number(),
|
||||
oldLines: z.number(),
|
||||
newStart: z.number(),
|
||||
newLines: z.number(),
|
||||
lines: z.array(z.string()),
|
||||
}).strict()
|
||||
13
src/lib/conversation-schema/tool/TodoSchema.ts
Normal file
13
src/lib/conversation-schema/tool/TodoSchema.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import z from "zod";
|
||||
|
||||
const TodoSchema = z.object({
|
||||
content: z.string(),
|
||||
status: z.enum(['pending', 'in_progress', 'completed']),
|
||||
priority: z.enum(['low', 'medium', 'high']),
|
||||
id: z.string(),
|
||||
}).strict()
|
||||
|
||||
export const TodoToolResultSchema = z.object({
|
||||
oldTodos: z.array(TodoSchema).optional(),
|
||||
newTodos: z.array(TodoSchema).optional(),
|
||||
}).strict()
|
||||
9
src/lib/conversation-schema/tool/index.ts
Normal file
9
src/lib/conversation-schema/tool/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { TodoToolResultSchema } from "./TodoSchema";
|
||||
import { CommonToolResultSchema } from "./CommonToolSchema";
|
||||
|
||||
export const ToolUseResultSchema = z.union([
|
||||
z.string(),
|
||||
TodoToolResultSchema,
|
||||
CommonToolResultSchema,
|
||||
]);
|
||||
@@ -1,20 +1,32 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import type { HonoAppType } from "./app";
|
||||
|
||||
const helloBodySchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
import { getProjects } from "../service/project/getProjects";
|
||||
import { getProject } from "../service/project/getProject";
|
||||
import { getSessions } from "../service/session/getSessions";
|
||||
import { getSession } from "../service/session/getSession";
|
||||
|
||||
export const routes = (app: HonoAppType) => {
|
||||
return (
|
||||
app
|
||||
// routes
|
||||
.get("/hello", zValidator("json", helloBodySchema), (c) => {
|
||||
const { name } = c.req.valid("json");
|
||||
return c.json({ message: `Hello ${name}` });
|
||||
})
|
||||
);
|
||||
return app
|
||||
.get("/projects", async (c) => {
|
||||
const { projects } = await getProjects();
|
||||
return c.json({ projects });
|
||||
})
|
||||
|
||||
.get("/projects/:projectId", async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
|
||||
const [{ project }, { sessions }] = await Promise.all([
|
||||
getProject(projectId),
|
||||
getSessions(projectId),
|
||||
] as const);
|
||||
|
||||
return c.json({ project, sessions });
|
||||
})
|
||||
|
||||
.get("/projects/:projectId/sessions/:sessionId", async (c) => {
|
||||
const { projectId, sessionId } = c.req.param();
|
||||
const { session } = await getSession(projectId, sessionId);
|
||||
return c.json({ session });
|
||||
});
|
||||
};
|
||||
|
||||
export type RouteType = ReturnType<typeof routes>;
|
||||
|
||||
82
src/server/service/parseCommandXml.ts
Normal file
82
src/server/service/parseCommandXml.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const regExp = /<(?<tag>[^>]+)>(?<content>\s*[^<]*?\s*)<\/\k<tag>>/g;
|
||||
|
||||
const matchSchema = z.object({
|
||||
tag: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const parseCommandXml = (
|
||||
content: string
|
||||
):
|
||||
| {
|
||||
kind: "command";
|
||||
commandName: string;
|
||||
commandArgs: string;
|
||||
commandMessage?: string;
|
||||
}
|
||||
| {
|
||||
kind: "local-command-1";
|
||||
commandName: string;
|
||||
commandMessage: string;
|
||||
}
|
||||
| {
|
||||
kind: "local-command-2";
|
||||
stdout: string;
|
||||
}
|
||||
| {
|
||||
kind: "text";
|
||||
content: string;
|
||||
} => {
|
||||
const matches = Array.from(content.matchAll(regExp))
|
||||
.map((match) => matchSchema.safeParse(match.groups))
|
||||
.filter((result) => result.success)
|
||||
.map((result) => result.data);
|
||||
|
||||
if (matches.length === 0) {
|
||||
return {
|
||||
kind: "text",
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
const commandName = matches.find(
|
||||
(match) => match.tag === "command-name"
|
||||
)?.content;
|
||||
const commandArgs = matches.find(
|
||||
(match) => match.tag === "command-args"
|
||||
)?.content;
|
||||
const commandMessage = matches.find(
|
||||
(match) => match.tag === "command-message"
|
||||
)?.content;
|
||||
const localCommandStdout = matches.find(
|
||||
(match) => match.tag === "local-command-stdout"
|
||||
)?.content;
|
||||
|
||||
switch (true) {
|
||||
case commandName !== undefined && commandArgs !== undefined:
|
||||
return {
|
||||
kind: "command",
|
||||
commandName,
|
||||
commandArgs,
|
||||
commandMessage: commandMessage,
|
||||
};
|
||||
case commandName !== undefined && commandMessage !== undefined:
|
||||
return {
|
||||
kind: "local-command-1",
|
||||
commandName,
|
||||
commandMessage,
|
||||
};
|
||||
case localCommandStdout !== undefined:
|
||||
return {
|
||||
kind: "local-command-2",
|
||||
stdout: localCommandStdout,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
kind: "text",
|
||||
content,
|
||||
};
|
||||
}
|
||||
};
|
||||
18
src/server/service/parseJsonl.ts
Normal file
18
src/server/service/parseJsonl.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ConversationSchema } from "../../lib/conversation-schema";
|
||||
|
||||
export const parseJsonl = (content: string) => {
|
||||
const lines = content
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
|
||||
return lines.flatMap((line) => {
|
||||
const parsed = ConversationSchema.safeParse(JSON.parse(line));
|
||||
if (!parsed.success) {
|
||||
console.warn("Failed to parse jsonl, skipping", parsed.error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
});
|
||||
};
|
||||
4
src/server/service/paths.ts
Normal file
4
src/server/service/paths.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export const claudeProjectPath = resolve(homedir(), ".claude", "projects");
|
||||
24
src/server/service/project/getProject.ts
Normal file
24
src/server/service/project/getProject.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
import type { Project } from "../types";
|
||||
import { decodeProjectId } from "./id";
|
||||
import { getProjectMeta } from "./getProjectMeta";
|
||||
|
||||
export const getProject = async (
|
||||
projectId: string
|
||||
): Promise<{ project: Project }> => {
|
||||
const fullPath = decodeProjectId(projectId);
|
||||
if (!existsSync(fullPath)) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const meta = await getProjectMeta(fullPath);
|
||||
|
||||
return {
|
||||
project: {
|
||||
id: projectId,
|
||||
claudeProjectPath: fullPath,
|
||||
meta,
|
||||
},
|
||||
};
|
||||
};
|
||||
83
src/server/service/project/getProjectMeta.ts
Normal file
83
src/server/service/project/getProjectMeta.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { parseJsonl } from "../parseJsonl";
|
||||
import type { ProjectMeta } from "../types";
|
||||
|
||||
const projectMetaCache = new Map<string, ProjectMeta>();
|
||||
|
||||
const extractMetaFromJsonl = async (filePath: string) => {
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
let cwd: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const conversation = parseJsonl(line).at(0);
|
||||
|
||||
if (conversation === undefined || conversation.type === "summary") {
|
||||
continue;
|
||||
}
|
||||
|
||||
cwd = conversation.cwd;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
cwd,
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const getProjectMeta = async (
|
||||
claudeProjectPath: string
|
||||
): Promise<ProjectMeta> => {
|
||||
const cached = projectMetaCache.get(claudeProjectPath);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const dirents = await readdir(claudeProjectPath, { withFileTypes: true });
|
||||
const files = dirents
|
||||
.filter((d) => d.isFile() && d.name.endsWith(".jsonl"))
|
||||
.map(
|
||||
(d) =>
|
||||
({
|
||||
fullPath: resolve(d.parentPath, d.name),
|
||||
stats: statSync(resolve(d.parentPath, d.name)),
|
||||
} as const)
|
||||
)
|
||||
.toSorted((a, b) => {
|
||||
return a.stats.ctime.getTime() - b.stats.ctime.getTime();
|
||||
});
|
||||
|
||||
const lastModifiedUnixTime = files.at(-1)?.stats.ctime.getTime();
|
||||
|
||||
let cwd: string | null = null;
|
||||
|
||||
for (const file of files) {
|
||||
const result = await extractMetaFromJsonl(file.fullPath);
|
||||
|
||||
if (result.cwd === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cwd = result.cwd;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
const projectMeta: ProjectMeta = {
|
||||
projectName: cwd ? dirname(cwd) : null,
|
||||
projectPath: cwd,
|
||||
lastModifiedAt: lastModifiedUnixTime
|
||||
? new Date(lastModifiedUnixTime)
|
||||
: null,
|
||||
sessionCount: files.length,
|
||||
};
|
||||
|
||||
projectMetaCache.set(claudeProjectPath, projectMeta);
|
||||
|
||||
return projectMeta;
|
||||
};
|
||||
29
src/server/service/project/getProjects.ts
Normal file
29
src/server/service/project/getProjects.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { claudeProjectPath } from "../paths";
|
||||
import type { Project } from "../types";
|
||||
import { encodeProjectId } from "./id";
|
||||
import { getProjectMeta } from "./getProjectMeta";
|
||||
|
||||
export const getProjects = async (): Promise<{ projects: Project[] }> => {
|
||||
const dirents = await readdir(claudeProjectPath, { withFileTypes: true });
|
||||
const projects = await Promise.all(
|
||||
dirents
|
||||
.filter((d) => d.isDirectory())
|
||||
.map(async (d) => {
|
||||
const fullPath = resolve(d.parentPath, d.name);
|
||||
const id = encodeProjectId(fullPath);
|
||||
|
||||
return {
|
||||
id,
|
||||
claudeProjectPath: fullPath,
|
||||
meta: await getProjectMeta(fullPath),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
projects,
|
||||
};
|
||||
};
|
||||
7
src/server/service/project/id.ts
Normal file
7
src/server/service/project/id.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const encodeProjectId = (fullPath: string) => {
|
||||
return Buffer.from(fullPath).toString("base64url");
|
||||
};
|
||||
|
||||
export const decodeProjectId = (id: string) => {
|
||||
return Buffer.from(id, "base64url").toString("utf-8");
|
||||
};
|
||||
31
src/server/service/session/getSession.ts
Normal file
31
src/server/service/session/getSession.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { decodeProjectId } from "../project/id";
|
||||
import { resolve } from "node:path";
|
||||
import { parseJsonl } from "../parseJsonl";
|
||||
import type { SessionDetail } from "../types";
|
||||
import { getSessionMeta } from "./getSessionMeta";
|
||||
|
||||
export const getSession = async (
|
||||
projectId: string,
|
||||
sessionId: string
|
||||
): Promise<{
|
||||
session: SessionDetail;
|
||||
}> => {
|
||||
const projectPath = decodeProjectId(projectId);
|
||||
const sessionPath = resolve(projectPath, `${sessionId}.jsonl`);
|
||||
|
||||
const content = await readFile(sessionPath, "utf-8");
|
||||
|
||||
const conversations = parseJsonl(content);
|
||||
|
||||
const sessionDetail: SessionDetail = {
|
||||
id: sessionId,
|
||||
jsonlFilePath: sessionPath,
|
||||
meta: await getSessionMeta(sessionPath),
|
||||
conversations,
|
||||
};
|
||||
|
||||
return {
|
||||
session: sessionDetail,
|
||||
};
|
||||
};
|
||||
60
src/server/service/session/getSessionMeta.ts
Normal file
60
src/server/service/session/getSessionMeta.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { parseJsonl } from "../parseJsonl";
|
||||
import type { Conversation } from "../../../lib/conversation-schema";
|
||||
import type { SessionMeta } from "../types";
|
||||
|
||||
const sessionMetaCache = new Map<string, SessionMeta>();
|
||||
|
||||
export const getSessionMeta = async (
|
||||
jsonlFilePath: string
|
||||
): Promise<SessionMeta> => {
|
||||
const cached = sessionMetaCache.get(jsonlFilePath);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const stats = statSync(jsonlFilePath);
|
||||
const lastModifiedUnixTime = stats.ctime.getTime();
|
||||
|
||||
const content = await readFile(jsonlFilePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
let firstUserMessage: Conversation | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const conversation = parseJsonl(line).at(0);
|
||||
|
||||
if (conversation === undefined || conversation.type !== "user") {
|
||||
continue;
|
||||
}
|
||||
|
||||
firstUserMessage = conversation;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
const sessionMeta: SessionMeta = {
|
||||
messageCount: lines.length,
|
||||
firstContent:
|
||||
firstUserMessage === null
|
||||
? null
|
||||
: typeof firstUserMessage.message.content === "string"
|
||||
? firstUserMessage.message.content
|
||||
: (() => {
|
||||
const firstContent = firstUserMessage.message.content.at(0);
|
||||
if (firstContent === undefined) return null;
|
||||
if (typeof firstContent === "string") return firstContent;
|
||||
if (firstContent.type === "text") return firstContent.text;
|
||||
return null;
|
||||
})(),
|
||||
lastModifiedAt: lastModifiedUnixTime
|
||||
? new Date(lastModifiedUnixTime)
|
||||
: null,
|
||||
};
|
||||
|
||||
sessionMetaCache.set(jsonlFilePath, sessionMeta);
|
||||
|
||||
return sessionMeta;
|
||||
};
|
||||
31
src/server/service/session/getSessions.ts
Normal file
31
src/server/service/session/getSessions.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { decodeProjectId } from "../project/id";
|
||||
import type { Session } from "../types";
|
||||
import { getSessionMeta } from "./getSessionMeta";
|
||||
|
||||
export const getSessions = async (
|
||||
projectId: string
|
||||
): Promise<{ sessions: Session[] }> => {
|
||||
const claudeProjectPath = decodeProjectId(projectId);
|
||||
|
||||
const dirents = await readdir(claudeProjectPath, { withFileTypes: true });
|
||||
const sessions = await Promise.all(
|
||||
dirents
|
||||
.filter((d) => d.isFile() && d.name.endsWith(".jsonl"))
|
||||
.map(async (d): Promise<Session> => {
|
||||
const fullPath = resolve(d.parentPath, d.name);
|
||||
|
||||
return {
|
||||
id: d.name,
|
||||
jsonlFilePath: fullPath,
|
||||
meta: await getSessionMeta(fullPath),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
};
|
||||
};
|
||||
30
src/server/service/types.ts
Normal file
30
src/server/service/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Conversation } from "../../lib/conversation-schema";
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
claudeProjectPath: string;
|
||||
meta: ProjectMeta;
|
||||
};
|
||||
|
||||
export type ProjectMeta = {
|
||||
projectName: string | null;
|
||||
projectPath: string | null;
|
||||
lastModifiedAt: Date | null;
|
||||
sessionCount: number;
|
||||
};
|
||||
|
||||
export type Session = {
|
||||
id: string;
|
||||
jsonlFilePath: string;
|
||||
meta: SessionMeta;
|
||||
};
|
||||
|
||||
export type SessionMeta = {
|
||||
messageCount: number;
|
||||
firstContent: string | null;
|
||||
lastModifiedAt: Date | null;
|
||||
};
|
||||
|
||||
export type SessionDetail = Session & {
|
||||
conversations: Conversation[];
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "@tsconfig/strictest/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// transpile target
|
||||
"target": "ES2017",
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"noEmit": true,
|
||||
// module
|
||||
|
||||
Reference in New Issue
Block a user