newpr 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -1
- package/src/analyzer/pipeline.ts +22 -5
- package/src/cli/args.ts +6 -1
- package/src/cli/index.ts +2 -2
- package/src/github/fetch-pr.ts +43 -1
- package/src/history/store.ts +106 -1
- package/src/llm/cartoon.ts +128 -0
- package/src/llm/client.ts +197 -0
- package/src/llm/prompts.ts +33 -8
- package/src/tui/Shell.tsx +7 -2
- package/src/types/github.ts +11 -0
- package/src/types/output.ts +51 -0
- package/src/web/client/App.tsx +32 -2
- package/src/web/client/components/AppShell.tsx +94 -47
- package/src/web/client/components/ChatSection.tsx +427 -0
- package/src/web/client/components/DetailPane.tsx +163 -75
- package/src/web/client/components/DiffViewer.tsx +679 -0
- package/src/web/client/components/InputScreen.tsx +110 -26
- package/src/web/client/components/Markdown.tsx +169 -43
- package/src/web/client/components/ResultsScreen.tsx +135 -110
- package/src/web/client/components/TipTapEditor.tsx +405 -0
- package/src/web/client/hooks/useAnalysis.ts +8 -1
- package/src/web/client/hooks/useFeatures.ts +18 -0
- package/src/web/client/lib/shiki.ts +63 -0
- package/src/web/client/panels/CartoonPanel.tsx +153 -0
- package/src/web/client/panels/DiscussionPanel.tsx +158 -0
- package/src/web/client/panels/FilesPanel.tsx +435 -54
- package/src/web/client/panels/GroupsPanel.tsx +49 -40
- package/src/web/client/panels/StoryPanel.tsx +42 -22
- package/src/web/components/ui/tabs.tsx +3 -3
- package/src/web/server/routes.ts +752 -2
- package/src/web/server/session-manager.ts +11 -2
- package/src/web/server.ts +42 -2
- package/src/web/styles/built.css +1 -1
- package/src/web/styles/globals.css +117 -1
- package/src/web/client/panels/NarrativePanel.tsx +0 -9
- package/src/web/client/panels/SummaryPanel.tsx +0 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "newpr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "AI-powered large PR review tool - understand PRs with 1000+ lines of changes",
|
|
5
5
|
"module": "src/cli/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -61,16 +61,26 @@
|
|
|
61
61
|
"@radix-ui/react-slot": "^1.2.4",
|
|
62
62
|
"@radix-ui/react-tabs": "^1.1.13",
|
|
63
63
|
"@tailwindcss/cli": "^4.1.18",
|
|
64
|
+
"@tiptap/extension-mention": "^3.19.0",
|
|
65
|
+
"@tiptap/extension-placeholder": "^3.19.0",
|
|
66
|
+
"@tiptap/pm": "^3.19.0",
|
|
67
|
+
"@tiptap/react": "^3.19.0",
|
|
68
|
+
"@tiptap/starter-kit": "^3.19.0",
|
|
64
69
|
"class-variance-authority": "^0.7.1",
|
|
65
70
|
"clsx": "^2.1.1",
|
|
66
71
|
"ink": "6.6.0",
|
|
67
72
|
"ink-spinner": "5.0.0",
|
|
68
73
|
"ink-text-input": "6.0.0",
|
|
74
|
+
"katex": "^0.16.28",
|
|
69
75
|
"lucide-react": "^0.567.0",
|
|
70
76
|
"react": "19.1.0",
|
|
71
77
|
"react-dom": "19.1.0",
|
|
72
78
|
"react-markdown": "^10.1.0",
|
|
79
|
+
"rehype-katex": "^7.0.1",
|
|
80
|
+
"rehype-raw": "^7.0.0",
|
|
73
81
|
"remark-gfm": "^4.0.1",
|
|
82
|
+
"remark-math": "^6.0.0",
|
|
83
|
+
"shiki": "^3.22.0",
|
|
74
84
|
"tailwind-merge": "^3.4.1",
|
|
75
85
|
"tailwindcss": "^4.1.18",
|
|
76
86
|
"tailwindcss-animate": "^1.0.7"
|
package/src/analyzer/pipeline.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { ExplorationResult } from "../workspace/types.ts";
|
|
|
6
6
|
import type { AgentToolName } from "../workspace/types.ts";
|
|
7
7
|
import { parseDiff } from "../diff/parser.ts";
|
|
8
8
|
import { chunkDiff } from "../diff/chunker.ts";
|
|
9
|
-
import { fetchPrData } from "../github/fetch-pr.ts";
|
|
9
|
+
import { fetchPrData, fetchPrComments } from "../github/fetch-pr.ts";
|
|
10
10
|
import { fetchPrDiff } from "../github/fetch-diff.ts";
|
|
11
11
|
import { createLlmClient, type LlmClient, type LlmResponse } from "../llm/client.ts";
|
|
12
12
|
import {
|
|
@@ -50,6 +50,7 @@ interface PipelineOptions {
|
|
|
50
50
|
token: string;
|
|
51
51
|
config: NewprConfig;
|
|
52
52
|
onProgress?: ProgressCallback;
|
|
53
|
+
onFilePatches?: (patches: Record<string, string>) => void;
|
|
53
54
|
noClone?: boolean;
|
|
54
55
|
preferredAgent?: AgentToolName;
|
|
55
56
|
}
|
|
@@ -158,12 +159,13 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
|
|
|
158
159
|
timeout: config.timeout,
|
|
159
160
|
});
|
|
160
161
|
|
|
161
|
-
progress({ stage: "fetching", message: "Fetching PR data and
|
|
162
|
-
const [prData, rawDiff] = await Promise.all([
|
|
162
|
+
progress({ stage: "fetching", message: "Fetching PR data, diff, and discussion..." });
|
|
163
|
+
const [prData, rawDiff, prComments] = await Promise.all([
|
|
163
164
|
fetchPrData(pr, token),
|
|
164
165
|
fetchPrDiff(pr, token),
|
|
166
|
+
fetchPrComments(pr, token).catch(() => []),
|
|
165
167
|
]);
|
|
166
|
-
progress({ stage: "fetching", message: `#${prData.number} "${prData.title}" by ${prData.author} · +${prData.additions} −${prData.deletions}` });
|
|
168
|
+
progress({ stage: "fetching", message: `#${prData.number} "${prData.title}" by ${prData.author} · +${prData.additions} −${prData.deletions} · ${prComments.length} comments` });
|
|
167
169
|
|
|
168
170
|
progress({ stage: "parsing", message: "Parsing diff..." });
|
|
169
171
|
const parsed = parseDiff(rawDiff);
|
|
@@ -175,6 +177,15 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
|
|
|
175
177
|
const totalDel = chunks.reduce((s, c) => s + c.deletions, 0);
|
|
176
178
|
progress({ stage: "parsing", message: `${chunks.length} files · +${totalAdd} −${totalDel}${wasTruncated ? ` (${allChunks.length - config.max_files} skipped)` : ""}` });
|
|
177
179
|
|
|
180
|
+
const changedFilesSet = new Set(changedFiles);
|
|
181
|
+
const filePatches: Record<string, string> = {};
|
|
182
|
+
for (const fileDiff of parsed.files) {
|
|
183
|
+
if (changedFilesSet.has(fileDiff.path)) {
|
|
184
|
+
filePatches[fileDiff.path] = fileDiff.raw;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
options.onFilePatches?.(filePatches);
|
|
188
|
+
|
|
178
189
|
let exploration: ExplorationResult | null = null;
|
|
179
190
|
if (!noClone) {
|
|
180
191
|
exploration = await tryExploreCodebase(
|
|
@@ -183,7 +194,12 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
|
|
|
183
194
|
);
|
|
184
195
|
}
|
|
185
196
|
|
|
186
|
-
const promptCtx: PromptContext = {
|
|
197
|
+
const promptCtx: PromptContext = {
|
|
198
|
+
commits: prData.commits,
|
|
199
|
+
language: config.language,
|
|
200
|
+
prBody: prData.body,
|
|
201
|
+
discussion: prComments.map((c) => ({ author: c.author, body: c.body })),
|
|
202
|
+
};
|
|
187
203
|
const enrichedTag = exploration ? " + codebase context" : "";
|
|
188
204
|
|
|
189
205
|
progress({
|
|
@@ -279,6 +295,7 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
|
|
|
279
295
|
meta: {
|
|
280
296
|
pr_number: prData.number,
|
|
281
297
|
pr_title: prData.title,
|
|
298
|
+
pr_body: prData.body || undefined,
|
|
282
299
|
pr_url: prData.url,
|
|
283
300
|
base_branch: prData.base_branch,
|
|
284
301
|
head_branch: prData.head_branch,
|
package/src/cli/args.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface CliArgs {
|
|
|
10
10
|
noClone: boolean;
|
|
11
11
|
agent?: AgentToolName;
|
|
12
12
|
port?: number;
|
|
13
|
+
cartoon?: boolean;
|
|
13
14
|
subArgs: string[];
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -101,8 +102,12 @@ export function parseArgs(argv: string[]): CliArgs {
|
|
|
101
102
|
const portIdx = args.indexOf("--port");
|
|
102
103
|
if (portIdx !== -1 && args[portIdx + 1]) {
|
|
103
104
|
port = Number.parseInt(args[portIdx + 1]!, 10) || 3000;
|
|
105
|
+
} else {
|
|
106
|
+
const eqArg = args.find((a) => a.startsWith("--port="));
|
|
107
|
+
if (eqArg) port = Number.parseInt(eqArg.split("=")[1]!, 10) || 3000;
|
|
104
108
|
}
|
|
105
|
-
|
|
109
|
+
const cartoon = args.includes("--cartoon");
|
|
110
|
+
return { command: "web", port, cartoon, ...DEFAULTS };
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
if (args.length === 0) {
|
package/src/cli/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { createStderrProgress, createSilentProgress, createStreamJsonProgress }
|
|
|
11
11
|
import { renderLoading, renderShell } from "../tui/render.tsx";
|
|
12
12
|
import { checkForUpdate, printUpdateNotice } from "./update-check.ts";
|
|
13
13
|
|
|
14
|
-
const VERSION = "0.1.
|
|
14
|
+
const VERSION = "0.1.3";
|
|
15
15
|
|
|
16
16
|
async function main(): Promise<void> {
|
|
17
17
|
const args = parseArgs(process.argv);
|
|
@@ -56,7 +56,7 @@ async function main(): Promise<void> {
|
|
|
56
56
|
const updateInfo = await updatePromise;
|
|
57
57
|
if (updateInfo) printUpdateNotice(updateInfo);
|
|
58
58
|
const { startWebServer } = await import("../web/server.ts");
|
|
59
|
-
await startWebServer({ port: args.port ?? 3000, token, config });
|
|
59
|
+
await startWebServer({ port: args.port ?? 3000, token, config, cartoon: args.cartoon });
|
|
60
60
|
} catch (error) {
|
|
61
61
|
const message = error instanceof Error ? error.message : String(error);
|
|
62
62
|
process.stderr.write(`Error: ${message}\n`);
|
package/src/github/fetch-pr.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { GithubPrData, PrCommit, PrIdentifier } from "../types/github.ts";
|
|
1
|
+
import type { GithubPrData, PrComment, PrCommit, PrIdentifier } from "../types/github.ts";
|
|
2
2
|
|
|
3
3
|
export function mapPrResponse(json: Record<string, unknown>): Omit<GithubPrData, "commits"> {
|
|
4
4
|
const user = json.user as Record<string, unknown> | undefined;
|
|
@@ -8,6 +8,7 @@ export function mapPrResponse(json: Record<string, unknown>): Omit<GithubPrData,
|
|
|
8
8
|
return {
|
|
9
9
|
number: json.number as number,
|
|
10
10
|
title: json.title as string,
|
|
11
|
+
body: (json.body as string) ?? "",
|
|
11
12
|
url: json.html_url as string,
|
|
12
13
|
base_branch: (base?.ref as string) ?? "unknown",
|
|
13
14
|
head_branch: (head?.ref as string) ?? "unknown",
|
|
@@ -88,3 +89,44 @@ export async function fetchPrData(pr: PrIdentifier, token: string): Promise<Gith
|
|
|
88
89
|
|
|
89
90
|
return { ...base, commits };
|
|
90
91
|
}
|
|
92
|
+
|
|
93
|
+
interface GithubCommentResponse {
|
|
94
|
+
id: number;
|
|
95
|
+
user: { login: string; avatar_url?: string } | null;
|
|
96
|
+
body: string;
|
|
97
|
+
created_at: string;
|
|
98
|
+
updated_at: string;
|
|
99
|
+
html_url: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function fetchPrComments(pr: PrIdentifier, token: string): Promise<PrComment[]> {
|
|
103
|
+
const allComments: GithubCommentResponse[] = [];
|
|
104
|
+
let page = 1;
|
|
105
|
+
|
|
106
|
+
while (true) {
|
|
107
|
+
const url = `https://api.github.com/repos/${pr.owner}/${pr.repo}/issues/${pr.number}/comments?per_page=100&page=${page}`;
|
|
108
|
+
const response = await githubGet(url, token);
|
|
109
|
+
const items = (await response.json()) as GithubCommentResponse[];
|
|
110
|
+
if (items.length === 0) break;
|
|
111
|
+
allComments.push(...items);
|
|
112
|
+
if (items.length < 100) break;
|
|
113
|
+
page++;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return allComments.map((c) => ({
|
|
117
|
+
id: c.id,
|
|
118
|
+
author: c.user?.login ?? "unknown",
|
|
119
|
+
author_avatar: c.user?.avatar_url ?? undefined,
|
|
120
|
+
body: c.body,
|
|
121
|
+
created_at: c.created_at,
|
|
122
|
+
updated_at: c.updated_at,
|
|
123
|
+
html_url: c.html_url,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function fetchPrBody(pr: PrIdentifier, token: string): Promise<string> {
|
|
128
|
+
const url = `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`;
|
|
129
|
+
const response = await githubGet(url, token);
|
|
130
|
+
const json = (await response.json()) as Record<string, unknown>;
|
|
131
|
+
return (json.body as string) ?? "";
|
|
132
|
+
}
|
package/src/history/store.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { mkdirSync, rmSync, existsSync } from "node:fs";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
|
-
import type { NewprOutput } from "../types/output.ts";
|
|
5
|
+
import type { NewprOutput, DiffComment, ChatMessage, CartoonImage } from "../types/output.ts";
|
|
6
6
|
import type { SessionRecord } from "./types.ts";
|
|
7
7
|
|
|
8
8
|
const HISTORY_DIR = join(homedir(), ".newpr", "history");
|
|
@@ -91,6 +91,111 @@ export async function clearHistory(): Promise<void> {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
export async function savePatchesSidecar(
|
|
95
|
+
id: string,
|
|
96
|
+
patches: Record<string, string>,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
ensureDirs();
|
|
99
|
+
await Bun.write(
|
|
100
|
+
join(SESSIONS_DIR, `${id}.patches.json`),
|
|
101
|
+
JSON.stringify(patches),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function loadPatchesSidecar(
|
|
106
|
+
id: string,
|
|
107
|
+
): Promise<Record<string, string> | null> {
|
|
108
|
+
try {
|
|
109
|
+
const filePath = join(SESSIONS_DIR, `${id}.patches.json`);
|
|
110
|
+
const file = Bun.file(filePath);
|
|
111
|
+
if (!(await file.exists())) return null;
|
|
112
|
+
return JSON.parse(await file.text()) as Record<string, string>;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function loadSinglePatch(
|
|
119
|
+
id: string,
|
|
120
|
+
filePath: string,
|
|
121
|
+
): Promise<string | null> {
|
|
122
|
+
const patches = await loadPatchesSidecar(id);
|
|
123
|
+
if (!patches) return null;
|
|
124
|
+
return patches[filePath] ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function saveCommentsSidecar(
|
|
128
|
+
id: string,
|
|
129
|
+
comments: DiffComment[],
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
ensureDirs();
|
|
132
|
+
await Bun.write(
|
|
133
|
+
join(SESSIONS_DIR, `${id}.comments.json`),
|
|
134
|
+
JSON.stringify(comments, null, 2),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function loadCommentsSidecar(
|
|
139
|
+
id: string,
|
|
140
|
+
): Promise<DiffComment[] | null> {
|
|
141
|
+
try {
|
|
142
|
+
const filePath = join(SESSIONS_DIR, `${id}.comments.json`);
|
|
143
|
+
const file = Bun.file(filePath);
|
|
144
|
+
if (!(await file.exists())) return null;
|
|
145
|
+
return JSON.parse(await file.text()) as DiffComment[];
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function saveChatSidecar(
|
|
152
|
+
id: string,
|
|
153
|
+
messages: ChatMessage[],
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
ensureDirs();
|
|
156
|
+
await Bun.write(
|
|
157
|
+
join(SESSIONS_DIR, `${id}.chat.json`),
|
|
158
|
+
JSON.stringify(messages, null, 2),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function loadChatSidecar(
|
|
163
|
+
id: string,
|
|
164
|
+
): Promise<ChatMessage[] | null> {
|
|
165
|
+
try {
|
|
166
|
+
const filePath = join(SESSIONS_DIR, `${id}.chat.json`);
|
|
167
|
+
const file = Bun.file(filePath);
|
|
168
|
+
if (!(await file.exists())) return null;
|
|
169
|
+
return JSON.parse(await file.text()) as ChatMessage[];
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function saveCartoonSidecar(
|
|
176
|
+
id: string,
|
|
177
|
+
cartoon: CartoonImage,
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
ensureDirs();
|
|
180
|
+
await Bun.write(
|
|
181
|
+
join(SESSIONS_DIR, `${id}.cartoon.json`),
|
|
182
|
+
JSON.stringify(cartoon),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function loadCartoonSidecar(
|
|
187
|
+
id: string,
|
|
188
|
+
): Promise<CartoonImage | null> {
|
|
189
|
+
try {
|
|
190
|
+
const filePath = join(SESSIONS_DIR, `${id}.cartoon.json`);
|
|
191
|
+
const file = Bun.file(filePath);
|
|
192
|
+
if (!(await file.exists())) return null;
|
|
193
|
+
return JSON.parse(await file.text()) as CartoonImage;
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
94
199
|
export function getHistoryPath(): string {
|
|
95
200
|
return HISTORY_DIR;
|
|
96
201
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { NewprOutput } from "../types/output.ts";
|
|
2
|
+
|
|
3
|
+
const CARTOON_MODEL = "google/gemini-3-pro-image-preview";
|
|
4
|
+
|
|
5
|
+
function buildCartoonPrompt(data: NewprOutput, language: string): string {
|
|
6
|
+
const { meta, summary, groups, narrative } = data;
|
|
7
|
+
const lang = language === "auto" ? "English" : language;
|
|
8
|
+
const groupList = groups.slice(0, 5).map((g) => `- ${g.name}: ${g.description.slice(0, 80)}`).join("\n");
|
|
9
|
+
const storyExcerpt = narrative
|
|
10
|
+
.replace(/\[\[(group|file):[^\]]+\]\]/g, "")
|
|
11
|
+
.split("\n")
|
|
12
|
+
.filter((l) => l.trim() && !l.startsWith("#"))
|
|
13
|
+
.slice(0, 6)
|
|
14
|
+
.join(" ")
|
|
15
|
+
.slice(0, 500);
|
|
16
|
+
|
|
17
|
+
return `Generate an image of a funny 4-panel comic strip about this Pull Request.
|
|
18
|
+
|
|
19
|
+
## PR Context
|
|
20
|
+
Title: "${meta.pr_title}"
|
|
21
|
+
Author: ${meta.author}
|
|
22
|
+
Purpose: ${summary.purpose}
|
|
23
|
+
Scope: ${summary.scope}
|
|
24
|
+
Impact: ${summary.impact}
|
|
25
|
+
Risk: ${summary.risk_level}
|
|
26
|
+
Changes: +${meta.total_additions} -${meta.total_deletions} across ${meta.total_files_changed} files
|
|
27
|
+
|
|
28
|
+
## What happened (key changes):
|
|
29
|
+
${groupList}
|
|
30
|
+
|
|
31
|
+
## Story:
|
|
32
|
+
${storyExcerpt}
|
|
33
|
+
|
|
34
|
+
## Comic Requirements:
|
|
35
|
+
- 2x2 grid, 4 panels
|
|
36
|
+
- Cute stick-figure developer characters with expressive faces and gestures
|
|
37
|
+
- Speech bubbles with SHORT, witty dialogue in ${lang}
|
|
38
|
+
- Panel 1: The developer discovers the problem or receives the task (based on the PR purpose above)
|
|
39
|
+
- Panel 2: The developer's ambitious plan or approach (based on the actual changes)
|
|
40
|
+
- Panel 3: A funny complication that reflects the real complexity (based on risk/impact)
|
|
41
|
+
- Panel 4: The resolution with a developer humor punchline
|
|
42
|
+
- The humor should be SPECIFIC to this PR's content, not generic programming jokes
|
|
43
|
+
- Make the characters expressive and the scenes detailed
|
|
44
|
+
- The image must be square (1:1 aspect ratio, 1080x1080px), suitable for Instagram
|
|
45
|
+
- Output only the image`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface CartoonResponse {
|
|
49
|
+
choices: Array<{
|
|
50
|
+
message: {
|
|
51
|
+
content: string;
|
|
52
|
+
images?: Array<{
|
|
53
|
+
image_url: { url: string };
|
|
54
|
+
}>;
|
|
55
|
+
};
|
|
56
|
+
}>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const MAX_RETRIES = 3;
|
|
60
|
+
|
|
61
|
+
export async function generateCartoon(
|
|
62
|
+
apiKey: string,
|
|
63
|
+
data: NewprOutput,
|
|
64
|
+
language: string,
|
|
65
|
+
): Promise<{ imageBase64: string; mimeType: string }> {
|
|
66
|
+
const prompt = buildCartoonPrompt(data, language);
|
|
67
|
+
|
|
68
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
69
|
+
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: {
|
|
72
|
+
Authorization: `Bearer ${apiKey}`,
|
|
73
|
+
"Content-Type": "application/json",
|
|
74
|
+
"HTTP-Referer": "https://github.com/jiwonMe/newpr",
|
|
75
|
+
"X-Title": "newpr-cartoon",
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
model: CARTOON_MODEL,
|
|
79
|
+
messages: [
|
|
80
|
+
{ role: "user", content: prompt },
|
|
81
|
+
],
|
|
82
|
+
modalities: ["image", "text"],
|
|
83
|
+
temperature: 1.0,
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (response.status === 500 || response.status === 502 || response.status === 503) {
|
|
88
|
+
if (attempt < MAX_RETRIES) {
|
|
89
|
+
await new Promise((r) => setTimeout(r, 2000 * attempt));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
const body = await response.text();
|
|
96
|
+
throw new Error(`Cartoon generation failed (${response.status}): ${body.slice(0, 300)}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const result = await response.json() as CartoonResponse;
|
|
100
|
+
const message = result.choices?.[0]?.message;
|
|
101
|
+
|
|
102
|
+
if (message?.images?.length) {
|
|
103
|
+
const imageUrl = message.images[0]!.image_url.url;
|
|
104
|
+
const match = imageUrl.match(/^data:image\/(png|jpeg|webp|gif);base64,(.+)$/);
|
|
105
|
+
if (match) {
|
|
106
|
+
return { imageBase64: match[2]!, mimeType: `image/${match[1]}` };
|
|
107
|
+
}
|
|
108
|
+
const imgRes = await fetch(imageUrl);
|
|
109
|
+
if (imgRes.ok) {
|
|
110
|
+
const buf = await imgRes.arrayBuffer();
|
|
111
|
+
return {
|
|
112
|
+
imageBase64: Buffer.from(buf).toString("base64"),
|
|
113
|
+
mimeType: imgRes.headers.get("content-type") ?? "image/png",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (attempt < MAX_RETRIES) {
|
|
119
|
+
await new Promise((r) => setTimeout(r, 2000 * attempt));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const raw = JSON.stringify(result).slice(0, 500);
|
|
124
|
+
throw new Error(`No image in response. Raw: ${raw}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
throw new Error("Cartoon generation failed after retries");
|
|
128
|
+
}
|
package/src/llm/client.ts
CHANGED
|
@@ -238,3 +238,200 @@ export function createLlmClient(options: LlmClientOptions): LlmClient {
|
|
|
238
238
|
const { createClaudeCodeClient: create } = require("./claude-code-client.ts");
|
|
239
239
|
return create(options.timeout);
|
|
240
240
|
}
|
|
241
|
+
|
|
242
|
+
export interface ChatTool {
|
|
243
|
+
type: "function";
|
|
244
|
+
function: {
|
|
245
|
+
name: string;
|
|
246
|
+
description: string;
|
|
247
|
+
parameters: Record<string, unknown>;
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface ChatToolCallDelta {
|
|
252
|
+
id: string;
|
|
253
|
+
name: string;
|
|
254
|
+
arguments: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface ChatStreamEvent {
|
|
258
|
+
type: "text" | "tool_call" | "tool_result" | "done" | "error";
|
|
259
|
+
content?: string;
|
|
260
|
+
toolCall?: ChatToolCallDelta;
|
|
261
|
+
toolResult?: { id: string; result: string };
|
|
262
|
+
error?: string;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export type ChatStreamCallback = (event: ChatStreamEvent) => void;
|
|
266
|
+
|
|
267
|
+
interface OpenRouterChatMessage {
|
|
268
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
269
|
+
content?: string | null;
|
|
270
|
+
tool_calls?: Array<{
|
|
271
|
+
id: string;
|
|
272
|
+
type: "function";
|
|
273
|
+
function: { name: string; arguments: string };
|
|
274
|
+
}>;
|
|
275
|
+
tool_call_id?: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
interface OpenRouterStreamToolCallDelta {
|
|
279
|
+
index?: number;
|
|
280
|
+
id?: string;
|
|
281
|
+
type?: string;
|
|
282
|
+
function?: { name?: string; arguments?: string };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface OpenRouterStreamChunkWithTools {
|
|
286
|
+
choices: Array<{
|
|
287
|
+
delta: {
|
|
288
|
+
content?: string;
|
|
289
|
+
tool_calls?: OpenRouterStreamToolCallDelta[];
|
|
290
|
+
};
|
|
291
|
+
finish_reason?: string | null;
|
|
292
|
+
}>;
|
|
293
|
+
model?: string;
|
|
294
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function chatWithTools(
|
|
298
|
+
options: LlmClientOptions,
|
|
299
|
+
messages: OpenRouterChatMessage[],
|
|
300
|
+
tools: ChatTool[],
|
|
301
|
+
executeTool: (name: string, args: Record<string, unknown>) => Promise<string>,
|
|
302
|
+
onEvent: ChatStreamCallback,
|
|
303
|
+
): Promise<void> {
|
|
304
|
+
const MAX_TOOL_ROUNDS = 10;
|
|
305
|
+
|
|
306
|
+
let currentMessages = [...messages];
|
|
307
|
+
|
|
308
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
309
|
+
const controller = new AbortController();
|
|
310
|
+
const timeoutId = setTimeout(() => controller.abort(), options.timeout * 1000);
|
|
311
|
+
|
|
312
|
+
let response: Response;
|
|
313
|
+
try {
|
|
314
|
+
response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
315
|
+
method: "POST",
|
|
316
|
+
signal: controller.signal,
|
|
317
|
+
headers: {
|
|
318
|
+
Authorization: `Bearer ${options.api_key}`,
|
|
319
|
+
"Content-Type": "application/json",
|
|
320
|
+
"HTTP-Referer": "https://github.com/sionic/newpr",
|
|
321
|
+
"X-Title": "newpr",
|
|
322
|
+
},
|
|
323
|
+
body: JSON.stringify({
|
|
324
|
+
model: options.model,
|
|
325
|
+
messages: currentMessages,
|
|
326
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
327
|
+
temperature: 0.3,
|
|
328
|
+
stream: true,
|
|
329
|
+
}),
|
|
330
|
+
});
|
|
331
|
+
} catch (err) {
|
|
332
|
+
clearTimeout(timeoutId);
|
|
333
|
+
onEvent({ type: "error", error: err instanceof Error ? err.message : String(err) });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
clearTimeout(timeoutId);
|
|
338
|
+
|
|
339
|
+
if (!response.ok) {
|
|
340
|
+
const body = await response.text();
|
|
341
|
+
onEvent({ type: "error", error: `OpenRouter API error ${response.status}: ${body}` });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const reader = response.body!.getReader();
|
|
346
|
+
const decoder = new TextDecoder();
|
|
347
|
+
let buffer = "";
|
|
348
|
+
let textContent = "";
|
|
349
|
+
const toolCalls = new Map<number, { id: string; name: string; arguments: string }>();
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
while (true) {
|
|
353
|
+
const { done, value } = await reader.read();
|
|
354
|
+
if (done) break;
|
|
355
|
+
|
|
356
|
+
buffer += decoder.decode(value, { stream: true });
|
|
357
|
+
const lines = buffer.split("\n");
|
|
358
|
+
buffer = lines.pop() ?? "";
|
|
359
|
+
|
|
360
|
+
for (const line of lines) {
|
|
361
|
+
const trimmed = line.trim();
|
|
362
|
+
if (!trimmed || trimmed === "data: [DONE]") continue;
|
|
363
|
+
if (!trimmed.startsWith("data: ")) continue;
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
const chunk = JSON.parse(trimmed.slice(6)) as OpenRouterStreamChunkWithTools;
|
|
367
|
+
const delta = chunk.choices[0]?.delta;
|
|
368
|
+
if (!delta) continue;
|
|
369
|
+
|
|
370
|
+
if (delta.content) {
|
|
371
|
+
textContent += delta.content;
|
|
372
|
+
onEvent({ type: "text", content: delta.content });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (delta.tool_calls) {
|
|
376
|
+
for (const tc of delta.tool_calls) {
|
|
377
|
+
const idx = tc.index ?? 0;
|
|
378
|
+
if (!toolCalls.has(idx)) {
|
|
379
|
+
toolCalls.set(idx, { id: tc.id ?? "", name: "", arguments: "" });
|
|
380
|
+
}
|
|
381
|
+
const entry = toolCalls.get(idx)!;
|
|
382
|
+
if (tc.id) entry.id = tc.id;
|
|
383
|
+
if (tc.function?.name) entry.name += tc.function.name;
|
|
384
|
+
if (tc.function?.arguments) entry.arguments += tc.function.arguments;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
} catch {}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
} finally {
|
|
391
|
+
reader.releaseLock();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (toolCalls.size === 0) {
|
|
395
|
+
onEvent({ type: "done" });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const assistantMsg: OpenRouterChatMessage = {
|
|
400
|
+
role: "assistant",
|
|
401
|
+
content: textContent || null,
|
|
402
|
+
tool_calls: [...toolCalls.values()].map((tc) => ({
|
|
403
|
+
id: tc.id,
|
|
404
|
+
type: "function" as const,
|
|
405
|
+
function: { name: tc.name, arguments: tc.arguments },
|
|
406
|
+
})),
|
|
407
|
+
};
|
|
408
|
+
currentMessages.push(assistantMsg);
|
|
409
|
+
|
|
410
|
+
for (const tc of toolCalls.values()) {
|
|
411
|
+
onEvent({
|
|
412
|
+
type: "tool_call",
|
|
413
|
+
toolCall: { id: tc.id, name: tc.name, arguments: tc.arguments },
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
let args: Record<string, unknown> = {};
|
|
417
|
+
try { args = JSON.parse(tc.arguments); } catch {}
|
|
418
|
+
|
|
419
|
+
let result: string;
|
|
420
|
+
try {
|
|
421
|
+
result = await executeTool(tc.name, args);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
result = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
onEvent({ type: "tool_result", toolResult: { id: tc.id, result } });
|
|
427
|
+
|
|
428
|
+
currentMessages.push({
|
|
429
|
+
role: "tool",
|
|
430
|
+
content: result,
|
|
431
|
+
tool_call_id: tc.id,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
onEvent({ type: "done" });
|
|
437
|
+
}
|