返回 CodeWhale
deepseek.ts
根目录 / web / lib / deepseek.ts
1 import type { CuratedDispatch, FeedItem, RepoStats } from "./types";
2
3 const FALLBACK_BASE = "https://api.deepseek.com";
4 const FALLBACK_MODEL = "deepseek-v4-flash";
5
6 interface ChatMessage {
7 role: "system" | "user" | "assistant";
8 content: string;
9 }
10
11 interface ChatResponse {
12 choices: { message: { content: string } }[];
13 }
14
15 export interface DeepSeekEnv {
16 baseUrl?: string;
17 model?: string;
18 }
19
20 export async function chat(
21 messages: ChatMessage[],
22 apiKey: string,
23 jsonMode = false,
24 dsEnv?: DeepSeekEnv
25 ): Promise<string> {
26 const base = dsEnv?.baseUrl ?? process.env.DEEPSEEK_BASE_URL ?? FALLBACK_BASE;
27 const model = dsEnv?.model ?? process.env.DEEPSEEK_MODEL ?? FALLBACK_MODEL;
28 const res = await fetch(`${base}/v1/chat/completions`, {
29 method: "POST",
30 headers: {
31 "Content-Type": "application/json",
32 Authorization: `Bearer ${apiKey}`,
33 },
34 body: JSON.stringify({
35 model,
36 messages,
37 temperature: 0.4,
38 max_tokens: 4096,
39 reasoning_effort: "high",
40 ...(jsonMode ? { response_format: { type: "json_object" } } : {}),
41 }),
42 });
43 if (!res.ok) {
44 const text = await res.text();
45 throw new Error(`DeepSeek ${res.status}: ${text}`);
46 }
47 const data = (await res.json()) as ChatResponse;
48 return data.choices[0]?.message?.content ?? "";
49 }
50
51 const SYSTEM_PROMPT = `You are the editor of "今日要闻 / Today's Dispatch", a daily-ish digest for the Codewhale open source project.
52
53 You receive: repo stats and a list of recently updated issues, PRs, and releases.
54 Output a single JSON object — no prose around it — matching this exact shape:
55
56 {
57 "headline": "string — English editorial headline (max ~70 chars)",
58 "summary": "string — 2-3 English sentences, calm factual editorial voice",
59 "highlights": [
60 { "title": "string", "href": "string", "tag": "shipped|merged|opened|discussion|release", "blurb": "one sentence, max ~120 chars" }
61 ],
62 "movers": [
63 { "number": 123, "title": "string", "href": "string", "reason": "one short clause" }
64 ],
65 "headlineZh": "string — Chinese (zh-CN) editorial headline, rewritten natively, not translated",
66 "summaryZh": "string — 2-3 Chinese sentences, native zh-CN prose",
67 "highlightsZh": [
68 { "title": "string — zh-CN native", "href": "string", "tag": "shipped|merged|opened|discussion|release", "blurb": "zh-CN one sentence" }
69 ],
70 "moversZh": [
71 { "number": 123, "title": "string — zh-CN", "href": "string", "reason": "zh-CN clause" }
72 ]
73 }
74
75 Rules:
76 - Pick 3-5 highlights and 3-5 movers from the actual provided items. Never invent.
77 - Prefer items with discussion, merged PRs, recent releases, or labelled "good first issue".
78 - Tone: like a small-paper editor — measured, specific, never breathless.
79 - Never use words like "exciting", "amazing", "powerful", "revolutionary" (or Chinese equivalents like 令人兴奋, 强大无比, 革命性).
80 - href must be the html_url provided.
81 - The zh-CN fields must be native Chinese prose — not a direct translation of the English fields. Write them as a Chinese-speaking maintainer would.
82 - zh-CN uses full-width punctuation in CJK sentences (。,、).`;
83
84 export async function curate(
85 apiKey: string,
86 stats: RepoStats,
87 feed: FeedItem[],
88 dsEnv?: DeepSeekEnv
89 ): Promise<CuratedDispatch> {
90 const trimmedFeed = feed.slice(0, 25).map((f) => ({
91 kind: f.kind,
92 number: f.number,
93 title: f.title,
94 state: f.state,
95 href: f.url,
96 author: f.author,
97 updated: f.updatedAt,
98 comments: f.comments,
99 labels: f.labels.map((l) => l.name),
100 }));
101
102 const userPayload = {
103 repo: "Hmbown/CodeWhale",
104 stats: {
105 stars: stats.stars,
106 forks: stats.forks,
107 open_issues: stats.openIssues,
108 open_pulls: stats.openPulls,
109 latest_release: stats.latestRelease?.tag,
110 },
111 recent: trimmedFeed,
112 };
113
114 const raw = await chat(
115 [
116 { role: "system", content: SYSTEM_PROMPT },
117 { role: "user", content: JSON.stringify(userPayload, null, 2) },
118 ],
119 apiKey,
120 true,
121 dsEnv
122 );
123
124 const parsed = JSON.parse(raw) as Omit<CuratedDispatch, "generatedAt">;
125 return { ...sanitizeDispatch(parsed), generatedAt: new Date().toISOString() };
126 }
127
128 const SAFE_HREF_RE = /^https:\/\/(?:github\.com|api\.github\.com|codewhale\.net|crates\.io|www\.npmjs\.com|docs\.rs)\//;
129 const FALLBACK_HREF = "https://github.com/Hmbown/CodeWhale";
130
131 function safeHref(u: unknown): string {
132 return typeof u === "string" && SAFE_HREF_RE.test(u) ? u : FALLBACK_HREF;
133 }
134
135 function sanitizeDispatch(d: Omit<CuratedDispatch, "generatedAt">): Omit<CuratedDispatch, "generatedAt"> {
136 return {
137 ...d,
138 highlights: (d.highlights ?? []).map((h) => ({ ...h, href: safeHref(h.href) })),
139 movers: (d.movers ?? []).map((m) => ({ ...m, href: safeHref(m.href) })),
140 highlightsZh: (d.highlightsZh ?? []).map((h) => ({ ...h, href: safeHref(h.href) })),
141 moversZh: (d.moversZh ?? []).map((m) => ({ ...m, href: safeHref(m.href) })),
142 };
143 }
144
144 lines TYPESCRIPT