summaryrefslogtreecommitdiff
path: root/frontend/src/pages/ProjectStrategyTab.svelte
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
commit5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch)
tree948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src/pages/ProjectStrategyTab.svelte
downloadstrategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/pages/ProjectStrategyTab.svelte')
-rw-r--r--frontend/src/pages/ProjectStrategyTab.svelte420
1 files changed, 420 insertions, 0 deletions
diff --git a/frontend/src/pages/ProjectStrategyTab.svelte b/frontend/src/pages/ProjectStrategyTab.svelte
new file mode 100644
index 0000000..5f2e242
--- /dev/null
+++ b/frontend/src/pages/ProjectStrategyTab.svelte
@@ -0,0 +1,420 @@
+<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 { 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 class="btn" disabled={snapshotBusy} onclick={snapshot}>
+ {snapshotBusy ? '创建快照…' : '保存版本快照'}
+ </button>
+ <button
+ class="btn"
+ 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 class="btn primary" onclick={keepMine}>保留本机修改(覆盖服务器)</button>
+ <button class="btn" 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 class="btn primary" 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
+ class="btn primary"
+ disabled={aiAcceptBusy}
+ onclick={acceptAI}
+ title={aiResult.base_generation !== baseGeneration ? '草稿代已变化,建议如需采用请重新获取' : ''}
+ >
+ {aiAcceptBusy ? '接受中…' : '接受为新版本'}
+ </button>
+ <button class="btn 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 class="btn small" onclick={() => void compareWithDraft(v)}>对比当前</button>
+ {#if i + 1 < versions.length}
+ <button class="btn small" onclick={() => void compareTwoVersions(i)}>与相邻旧版对比</button>
+ {/if}
+ <button class="btn small" 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>