1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
|
<script lang="ts">
import Status from '../components/Status.svelte';
import CodeEditor from '../components/CodeEditor.svelte';
import DiffView from '../components/DiffView.svelte';
import Modal from '../components/Modal.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { projectsStore } from '../lib/listsStore.svelte';
import {
getProject,
putDraft,
listVersions,
postVersion,
getVersionCode,
postRestore,
postAIAssist,
postAIAccept
} from '../lib/client';
import { extractStatusMessage } from '../lib/state';
import { fmtDateTime, versionSourceLabel } from '../lib/format';
import type { Project, Version, AIAssist } from '../lib/types';
interface Props {
projectId: string;
}
let { projectId }: Props = $props();
let code = $state('');
let baseGeneration = $state(0);
let baseCode = $state('');
let saveStatus = $state<'saved' | 'saving' | 'dirty' | 'conflict'>('saved');
let saveMsg = $state('');
let versions: Version[] = $state([]);
let versionsBusy = $state(false);
let versionsErr = $state<string | null>(null);
let snapshotBusy = $state(false);
let snapshotMsg = $state<string | null>(null);
let snapshotErr = $state<string | null>(null);
let snapshotMessage = $state('');
let conflictServer: { generation: number; code: string } | null = $state(null);
let comparing: { label: string; before: string; after: string } | null = $state(null);
let instruction = $state('');
let aiBusy = $state(false);
let aiErr = $state<string | null>(null);
let aiResult = $state<AIAssist | null>(null);
let aiAcceptBusy = $state(false);
let aiAcceptErr = $state<string | null>(null);
let aiNote = $state<string | null>(null);
let initBusy = $state(true);
let initErr = $state<string | null>(null);
let saveTimer: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
void init();
return () => {
if (saveTimer) clearTimeout(saveTimer);
};
});
async function init() {
initBusy = true;
initErr = null;
try {
const p: Project = await getProject(projectId);
projectsStore.bump(p);
code = p.draft_code ?? '';
baseGeneration = p.draft_generation;
baseCode = code;
await refreshVersions();
} catch (e) {
initErr = extractStatusMessage(e);
} finally {
initBusy = false;
}
}
async function refreshVersions() {
versionsBusy = true;
versionsErr = null;
try {
const { items } = await listVersions(projectId);
versions = items;
} catch (e) {
versionsErr = extractStatusMessage(e);
} finally {
versionsBusy = false;
}
}
function onEditorInput(v: string) {
if (saveStatus !== 'dirty') saveStatus = 'dirty';
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => void saveDraft(v), 800);
saveMsg = '输入暂停后自动保存';
}
async function saveDraft(payload?: string) {
const toSave = payload ?? code;
if (!toSave && !baseCode) return;
saveStatus = 'saving';
saveMsg = '保存中…';
try {
const p = await putDraft(projectId, toSave, baseGeneration);
code = toSave;
baseGeneration = p.draft_generation;
baseCode = toSave;
projectsStore.bump(p);
saveStatus = 'saved';
saveMsg = `已保存 · 草稿代 ${baseGeneration}`;
if (aiNote) aiNote = null;
} catch (e) {
const err = e as { status?: number; code?: string };
if (err.status === 409 && err.code === 'stale_generation') {
const fresh = await getProject(projectId);
saveStatus = 'conflict';
conflictServer = { generation: fresh.draft_generation, code: fresh.draft_code ?? '' };
saveMsg = '检测到冲突';
} else {
saveStatus = 'dirty';
saveMsg = extractStatusMessage(e);
}
}
}
async function keepMine() {
if (!conflictServer) return;
try {
const p = await putDraft(projectId, code, conflictServer.generation);
baseGeneration = p.draft_generation;
baseCode = code;
saveStatus = 'saved';
saveMsg = '已覆盖保存本机内容';
conflictServer = null;
} catch (e) {
saveMsg = extractStatusMessage(e);
}
}
function loadServer() {
if (!conflictServer) return;
code = conflictServer.code;
baseGeneration = conflictServer.generation;
baseCode = code;
saveStatus = 'saved';
saveMsg = '已采用服务器草稿';
conflictServer = null;
}
async function snapshot() {
snapshotBusy = true;
snapshotErr = null;
snapshotMsg = null;
try {
await postVersion(projectId, snapshotMessage.trim() || '保存版本快照');
snapshotMessage = '';
snapshotMsg = '版本快照已创建';
await refreshVersions();
} catch (e) {
snapshotErr = extractStatusMessage(e);
} finally {
snapshotBusy = false;
}
}
async function compareWithDraft(v: Version) {
versionsErr = null;
try {
const { code: vCode } = await getVersionCode(projectId, v.id);
comparing = { label: `版本 ${v.hash.slice(0, 10)} · ${v.message}`, before: vCode, after: code };
} catch (e) {
versionsErr = extractStatusMessage(e);
}
}
async function compareTwoVersions(i: number) {
const v = versions[i];
const prev = versions[i + 1];
if (!prev) return;
try {
const [a, b] = await Promise.all([getVersionCode(projectId, v.id), getVersionCode(projectId, prev.id)]);
comparing = {
label: `${a.code ? '' : ''}${v.hash.slice(0, 8)}(新)对比 ${prev.hash.slice(0, 8)}(旧)`,
before: b.code,
after: a.code
};
} catch (e) {
versionsErr = extractStatusMessage(e);
}
}
async function restore(v: Version) {
versionsErr = null;
try {
const p = await postRestore(projectId, v.id, baseGeneration);
code = p.draft_code ?? '';
baseGeneration = p.draft_generation;
baseCode = code;
projectsStore.bump(p);
versionsErr = null;
aiNote = `已从该版本恢复为新草稿(代 ${baseGeneration}),历史未改写`;
await refreshVersions();
} catch (e) {
versionsErr = extractStatusMessage(e);
}
}
async function askAI() {
if (!instruction.trim()) return;
aiBusy = true;
aiErr = null;
try {
const res = await postAIAssist(projectId, instruction.trim(), baseGeneration);
aiResult = res;
if (res.status && res.status !== 'ok') {
aiErr = `模型返回状态 ${res.status},未生成代码建议`;
}
} catch (e) {
aiErr = extractStatusMessage(e);
aiResult = null;
} finally {
aiBusy = false;
}
}
async function acceptAI() {
if (!aiResult) return;
aiAcceptBusy = true;
aiAcceptErr = null;
try {
const p = await postAIAccept(aiResult.id, baseGeneration);
code = p.draft_code ?? aiResult.proposed_code;
baseGeneration = p.draft_generation;
baseCode = code;
projectsStore.bump(p);
aiNote = 'AI 建议已接受,生成新的代码版本';
aiResult = null;
instruction = '';
await refreshVersions();
} catch (e) {
const err = e as { status?: number; code?: string };
if (err.status === 409) {
aiAcceptErr = '草稿代已变化,建议可能失效。请重新发起 AI 说明或刷新草稿后重试。';
} else {
aiAcceptErr = extractStatusMessage(e);
}
} finally {
aiAcceptBusy = false;
}
}
function discardAI() {
aiResult = null;
aiErr = null;
aiAcceptErr = null;
}
</script>
<div class="stack">
<Status busy={initBusy} error={initErr} busyText="加载策略草稿…" empty={null} />
{#if !initBusy && !initErr}
<div class="card stack">
<div class="spread">
<h2>策略代码(Backtrader Python)</h2>
<span class="save-status" role="status">
{saveStatus === 'saved' && `已保存${saveMsg ? ` · ${saveMsg}` : ''}`}
{#if saveStatus === 'dirty'}<span class="badge warn">有未保存修改</span>{/if}
{#if saveStatus === 'saving'}<span class="badge teal">保存中…</span>{/if}
{#if saveStatus === 'conflict'}<span class="badge danger">冲突</span>{/if}
</span>
</div>
<p class="hint">
编辑器修改会自动保存(约 0.8 秒停顿后提交)。草稿代 {baseGeneration} · 回测与获取 AI 建议使用当前草稿快照
</p>
<CodeEditor value={code} onInput={onEditorInput} readOnly={false} label="策略源码" height={430} />
<div class="spread wrap">
<Input
bind:value={snapshotMessage}
placeholder="快照说明,例如:加入RSI过滤器(可选)"
style="flex:2 1 240px"
onkeydown={(ev) => {
if (ev.key === 'Enter') void snapshot();
}}
/>
<Button disabled={snapshotBusy} onclick={snapshot}>
{snapshotBusy ? '创建快照…' : '保存版本快照'}
</Button>
<Button
variant="outline"
onclick={() => versions[1] && void compareWithDraft(versions[1])}
disabled={!versions[1]}
>
草稿与上一快照对比
</Button>
</div>
<Status busy={false} error={snapshotErr} empty={snapshotMsg} />
{#if conflictServer}
<div class="banner warn" role="alert">
<span aria-hidden="true">⚠</span>
<span>
服务器上的草稿已被其他修改更新(代 {conflictServer.generation}),你正在编辑的本地内容不会自动覆盖。
</span>
</div>
<DiffView before={conflictServer.code} after={code} beforeLabel="服务器" afterLabel="本机" />
<div class="row">
<Button onclick={keepMine}>保留本机修改(覆盖服务器)</Button>
<Button variant="outline" onclick={loadServer}>采用服务器版本</Button>
</div>
{/if}
</div>
<div class="stack">
<h2>AI 助手</h2>
<div class="card stack">
<label for="ai-instruction">希望 AI 帮你做什么?<span class="hint">例如:把均线周期改为两个可调参数并解释影响</span></label>
<textarea id="ai-instruction" rows="3" bind:value={instruction} placeholder="描述对策略的修改意图,AI 返回完整新源码与差异预览"></textarea>
<div class="row">
<Button disabled={aiBusy || !instruction.trim()} onclick={askAI}>
{aiBusy ? 'AI 处理中…' : '获取建议'}
</Button>
</div>
{#if aiErr}
<div class="banner error" role="alert">
<span aria-hidden="true">⚠</span>
<span>{aiErr}</span>
</div>
{/if}
{#if aiResult}
<div class="stack">
<div class="spread">
<h3>建议解释 <span class="hint">{aiResult.model}</span></h3>
<span class="hint">
tokens {aiResult.usage?.input_tokens ?? '—'} 入 / {aiResult.usage?.output_tokens ?? '—'} 出
</span>
</div>
<p>{aiResult.explanation}</p>
<h3>完整建议源码</h3>
<CodeEditor value={aiResult.proposed_code} onInput={() => {}} readOnly={true} label="AI 返回代码(只读)" height={280} />
<h3>与当前草稿的差异</h3>
<DiffView before={code} after={aiResult.proposed_code} beforeLabel="当前草稿" afterLabel="AI 建议" />
<div class="row">
<Button
disabled={aiAcceptBusy}
onclick={acceptAI}
title={aiResult.base_generation !== baseGeneration ? '草稿代已变化,建议如需采用请重新获取' : ''}
>
{aiAcceptBusy ? '接受中…' : '接受为新版本'}
</Button>
<Button variant="ghost" disabled={aiAcceptBusy} onclick={discardAI}>放弃建议</Button>
{#if aiResult.base_generation !== baseGeneration}
<span class="banner warn" role="alert">草稿代 {aiResult.base_generation} 与当前 {baseGeneration} 不一致,接受会失败;请重新发起说明</span>
{/if}
{#if aiAcceptErr}
<div class="banner error" role="alert">{aiAcceptErr}</div>
{/if}
</div>
</div>
{/if}
{#if aiNote}
<div class="banner info">{aiNote}</div>
{/if}
</div>
</div>
<div class="stack">
<h2>版本历史</h2>
<Status busy={versionsBusy} error={versionsErr} busyText="载入版本…" empty={null} />
{#if !versionsBusy && versions.length === 0}
<Status busy={false} empty="还没有版本快照" emptyHint="保存第一个快照后可以在历史中查看完整代码与恢复" />
{/if}
{#if !versionsBusy && versions.length > 0}
<div class="card" style="padding:0.4rem 0.6rem">
<table class="data">
<thead>
<tr>
<th>时间</th>
<th>摘要</th>
<th>哈希</th>
<th>来源</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{#each versions as v, i (v.id)}
<tr>
<td>{fmtDateTime(v.created_at)}</td>
<td>
{v.message?.trim() || '(无说明)'}
<span class="hint">版本代 {v.hash?.slice(0, 8) || '—'}</span>
</td>
<td class="num">{v.hash?.slice(0, 12)}</td>
<td><span class="badge grey">{versionSourceLabel[v.source]}</span></td>
<td>
<div class="row">
<Button variant="outline" size="sm" onclick={() => void compareWithDraft(v)}>对比当前</Button>
{#if i + 1 < versions.length}
<Button variant="outline" size="sm" onclick={() => void compareTwoVersions(i)}>与相邻旧版对比</Button>
{/if}
<Button size="sm" onclick={() => void restore(v)}>恢复为新草稿</Button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<p class="hint">版本为不可变记录,恢复均创建新草稿,从不改写历史。获取到最新草稿修改内容请刷新页面查看。</p>
</div>
{#if comparing}
<Modal title={comparing.label} onClose={() => (comparing = null)}>
<DiffView before={comparing.before} after={comparing.after} />
</Modal>
{/if}
{/if}
</div>
|