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