Skip to content

Commit d0f6de8

Browse files
committed
feat(ai): 精修发起 UI/编辑态 AI 入口/对话流式任务卡——目标 chips+档位+旋钮+提示词预览(REQ-245~247)
1 parent 5b30585 commit d0f6de8

16 files changed

Lines changed: 1111 additions & 111 deletions

‎app/src/components/AiRefineCard.tsx‎

Lines changed: 28 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,9 @@ import type {
1818
AiRefineResult,
1919
AiSettingsView,
2020
AiTaskState,
21-
BalanceView,
22-
RefineEstimateView,
2321
} from "../types";
2422
import RefineWorkbench from "./RefineWorkbench";
23+
import RefineLaunchDialog from "./RefineLaunchDialog";
2524

2625
const btn: React.CSSProperties = { padding: "5px 10px", cursor: "pointer", fontSize: 12, borderRadius: 6 };
2726

@@ -39,15 +38,14 @@ export default function AiRefineCard({
3938
}) {
4039
const [settings, setSettings] = useState<AiSettingsView | null>(null);
4140
const [phase, setPhase] = useState<"idle" | "consent" | "confirm" | "running" | "done" | "failed">("idle");
42-
const [estimate, setEstimate] = useState<RefineEstimateView | null>(null);
43-
const [balance, setBalance] = useState<BalanceView | null>(null);
4441
const [progress, setProgress] = useState<{ finished: number; total: number } | null>(null);
4542
const [result, setResult] = useState<AiRefineResult | null>(null);
4643
const [failure, setFailure] = useState<AiTaskFailureLike | null>(null);
47-
const [remember, setRemember] = useState(false);
4844
const [msg, setMsg] = useState("");
4945
const [, setTaskId] = useState<number | null>(null);
5046
const [showWorkbench, setShowWorkbench] = useState(false);
47+
// v0.17.0:策略发起对话框(目标/档位/旋钮/提示词预览/成本确认在对话框内)
48+
const [showLaunch, setShowLaunch] = useState(false);
5149
// v0.16.1 审查修复:工作台「重新生成」不经用户启动路径——旁路导航跳转
5250
// (否则每次 regenerate 都会 onTaskStarted → App 把你从会话页拽到 AI 对话页)
5351
const skipNavigateRef = useRef(false);
@@ -102,7 +100,7 @@ export default function AiRefineCard({
102100
setMsg("任务 30 秒无进展(可能未启动或后台卡住)——请查看 tauri 终端 [refine-task] 日志后重试");
103101
});
104102

105-
/** ① 预估 + 余额(确认弹窗数据源) */
103+
/** ① 快速检查 → 打开策略发起对话框(授权/成本确认/策略选择在对话框内) */
106104
const prepare = async () => {
107105
setMsg("");
108106
if (!settings?.enabled) {
@@ -115,36 +113,22 @@ export default function AiRefineCard({
115113
setPhase("idle");
116114
return;
117115
}
118-
if (!settings.authorized) {
119-
setPhase("consent");
120-
return;
121-
}
122-
const est = await invoke<RefineEstimateView>("ai_refine_estimate", { sessionId }).catch(() => null);
123-
const bal = await invoke<BalanceView>("ai_get_balance").catch(() => null);
124-
if (!est) {
125-
setMsg("成本预估失败,无法继续");
126-
return;
127-
}
128-
setEstimate(est);
129-
setBalance(bal);
130-
setRemember(est.rememberCostChoice);
131-
// 已"记住此选择"且同意过 → 跳过确认直接开始
132-
if (est.rememberCostChoice) {
133-
void start(est);
134-
} else {
135-
setPhase("confirm");
136-
}
116+
setShowLaunch(true);
137117
};
138118

139-
/** ② 启动任务(确认/授权通过后) */
140-
const start = async (est?: RefineEstimateView) => {
119+
/** ② 对话框确认启动回调(策略已由对话框传入——回接轮询/事件双通道) */
120+
const handleDialogStarted = useCallback((sid: number, taskId: number) => {
121+
setPhase("running");
122+
taskIdRef.current = taskId;
123+
// v0.16.1:会话页精修启动 → 自动跳 AI 对话页(任务卡入聊天线程/可追问)
124+
if (!skipNavigateRef.current) onTaskStarted?.(sid, taskId);
125+
setTaskId(taskId);
126+
startPolling(taskId);
127+
}, [onTaskStarted, startPolling]);
128+
129+
/** ② 启动任务(工作台「重新生成」路径——沿全局默认偏好;策略选择在主发起对话框) */
130+
const start = async () => {
141131
setMsg("");
142-
if (remember && !est) {
143-
// 勾选"记住" → 持久化偏好
144-
await invoke("ai_update_settings", {
145-
settings: { ...settings, rememberCostChoice: true },
146-
}).catch(() => undefined);
147-
}
148132
setPhase("running");
149133
// 契约修复(2026-08-21 真机"排队中"根因):Rust AiTaskHandle 为 camelCase
150134
// 序列化(taskId),此前读 handle.task_id 恒为 undefined → 事件被忽略、
@@ -177,12 +161,8 @@ export default function AiRefineCard({
177161
setResult(null);
178162
setFailure(null);
179163
setProgress(null);
180-
setEstimate(null);
181-
setBalance(null);
182164
};
183165

184-
const est = estimate?.estimate;
185-
186166
return (
187167
<div style={{ border: "1px solid #c7d2fe", borderRadius: 8, padding: 10, marginBottom: 8, background: "#f5f3ff" }}>
188168
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
@@ -204,56 +184,8 @@ export default function AiRefineCard({
204184
)}
205185
</div>
206186

207-
{/* 授权卡(首次:上传内容说明) */}
208-
{phase === "consent" && (
209-
<div style={{ border: "1px solid #f59e0b", background: "#fffbeb", borderRadius: 6, padding: 8, marginBottom: 6, fontSize: 12 }}>
210-
<div style={{ fontWeight: 600, marginBottom: 4 }}>首次使用需授权</div>
211-
精修将上传<strong>转写文本与最小上下文</strong>至 DeepSeek;本地优先铁律:<strong>音视频/图像永不出本机</strong>。是否同意?
212-
<div style={{ marginTop: 6, display: "flex", gap: 6 }}>
213-
<button style={{ ...btn, background: "#0d9488", color: "#fff", border: "none" }} onClick={async () => {
214-
await invoke("ai_set_authorized", { authorized: true }).catch(() => undefined);
215-
setSettings({ ...settings!, authorized: true });
216-
setPhase("confirm");
217-
}}>同意并继续</button>
218-
<button style={btn} onClick={() => setPhase("idle")}>暂不</button>
219-
</div>
220-
</div>
221-
)}
222-
223-
{/* 成本确认(REQ-143 基础版:token 预估 + 费用 + 内联余额) */}
224-
{phase === "confirm" && est && (
225-
<div style={{ border: "1px solid #e5e7eb", background: "#fff", borderRadius: 6, padding: 8, marginBottom: 6, fontSize: 12 }}>
226-
<div style={{ fontWeight: 600, marginBottom: 4 }}>成本确认</div>
227-
<div>
228-
预估 token:<strong>{est.estTokens}</strong> · 预估费用:<strong>¥{est.estCostYuan.toFixed(4)}</strong>
229-
{est.pricePer1m > 0 && <span style={{ color: "#6b7280" }}>(单价 ¥{est.pricePer1m}/1M token)</span>}
230-
{est.pricePer1m === 0 && <span style={{ color: "#0d9488" }}>(当前模型免费档 ¥0)</span>}
231-
{est.pricePer1m === 0 && est.priceKnown === false && <span style={{ color: "#d97706" }}>(该模型单价未登记,费用可能不准确)</span>}
232-
</div>
233-
{balance && (
234-
<div style={{ color: balance.lowBalanceWarning ? "#dc2626" : "#374151" }}>
235-
当前余额:<strong>¥{balance.balance.totalBalance.toFixed(2)}</strong>
236-
{balance.lowBalanceWarning && <span style={{ marginLeft: 6 }}>⚠️ {balance.lowBalanceWarning}</span>}
237-
</div>
238-
)}
239-
{/* F3-D:成本硬拦截提示——余额不足时启动会被后端拒绝(三出口引导) */}
240-
{balance && est.estCostYuan > 0 && balance.balance.totalBalance < est.estCostYuan * 1.2 && (
241-
<div style={{ color: "#b91c1c", marginTop: 2 }}>
242-
⚠️ 余额不足本次预估(含安全系数)——启动将被拦截,请充值或切换免费档模型
243-
</div>
244-
)}
245-
<label style={{ display: "flex", alignItems: "center", gap: 4, margin: "4px 0" }}>
246-
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
247-
记住此选择,下次不再确认
248-
</label>
249-
<div style={{ display: "flex", gap: 6 }}>
250-
<button style={{ ...btn, background: "#0d9488", color: "#fff", border: "none" }} onClick={() => void start()}>
251-
开始精修
252-
</button>
253-
<button style={btn} onClick={reset}>取消</button>
254-
</div>
255-
</div>
256-
)}
187+
{/* 授权卡由 RefineLaunchDialog 承载(v0.17.0——策略/成本/授权一屏编排) */}
188+
{phase === "consent" && null}
257189

258190
{/* 任务进行中(切片进度) */}
259191
{phase === "running" && (
@@ -315,6 +247,15 @@ export default function AiRefineCard({
315247
)}
316248

317249
{msg && <div style={{ fontSize: 11, color: msg.startsWith("已") ? "#0d9488" : "#dc2626", marginTop: 4 }}>{msg}</div>}
250+
251+
{/* v0.17.0:策略发起对话框(目标/档位/旋钮/预览/成本/授权) */}
252+
{showLaunch && (
253+
<RefineLaunchDialog
254+
sessionId={sessionId}
255+
onClose={() => setShowLaunch(false)}
256+
onStarted={handleDialogStarted}
257+
/>
258+
)}
318259
</div>
319260
);
320261
}

‎app/src/components/AiServicePanel.tsx‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
*/
1111
import { useCallback, useEffect, useState } from "react";
1212
import { invoke } from "@tauri-apps/api/core";
13-
import type { AiAuditEntry, AiSettingsView } from "../types";
13+
import type { AiAuditEntry, AiSettingsView, RefineStrategyMeta } from "../types";
14+
import RefineStrategyPicker from "./RefineStrategyPicker";
15+
import { draftFromPrefs, prefsFromDraft, type StrategyDraft } from "../utils/refineStrategy";
1416

1517
/** 通用小按钮样式(与各设置面板一致) */
1618
const btn: React.CSSProperties = {
@@ -36,6 +38,21 @@ export default function AiServicePanel() {
3638
const [consentVisible, setConsentVisible] = useState(false);
3739
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
3840
const [busy, setBusy] = useState(false);
41+
// v0.17.0:产出偏好(meta 声明 + 草稿——保存为全局默认)
42+
const [meta, setMeta] = useState<RefineStrategyMeta | null>(null);
43+
const [strategyDraft, setStrategyDraft] = useState<StrategyDraft | null>(null);
44+
45+
/** 装载:设置(含偏好)+ 策略声明 → 初始草稿(默认档位/目标) */
46+
const loadStrategy = useCallback(async (v: AiSettingsView | null) => {
47+
if (!v) return;
48+
try {
49+
const mt = await invoke<RefineStrategyMeta>("ai_refine_strategy_meta");
50+
setMeta(mt);
51+
setStrategyDraft(draftFromPrefs(v.refineStrategy, mt));
52+
} catch {
53+
/* 声明加载失败不阻塞面板(偏好区隐藏) */
54+
}
55+
}, []);
3956

4057
const load = useCallback(async () => {
4158
try {
@@ -58,6 +75,25 @@ export default function AiServicePanel() {
5875
void loadAudit();
5976
}, [load, loadAudit]);
6077

78+
// v0.17.0:设置(含偏好)加载后初始化策略草稿
79+
useEffect(() => {
80+
void loadStrategy(view);
81+
}, [view, loadStrategy]);
82+
83+
/** 保存产出偏好为全局默认(仅为偏离基准的最小集——语义见 prefsFromDraft) */
84+
const saveStrategy = async () => {
85+
if (!strategyDraft || !meta) return;
86+
setBusy(true);
87+
try {
88+
await invoke("ai_set_refine_strategy", { prefs: prefsFromDraft(strategyDraft, meta) });
89+
setMsg({ kind: "ok", text: "已保存为默认产出偏好(下次精修默认采用)" });
90+
} catch (e) {
91+
setMsg({ kind: "err", text: `保存失败:${e}` });
92+
} finally {
93+
setBusy(false);
94+
}
95+
};
96+
6197
/** 开关切换:未授权时先弹授权确认卡,同意后才真正开启 */
6298
const toggleEnabled = async (on: boolean) => {
6399
if (on && view && !view.authorized) {
@@ -159,6 +195,28 @@ export default function AiServicePanel() {
159195
<span style={{ color: "#6b7280" }}>(默认关——图片随精修上传,仅视频会话生效)</span>
160196
</div>
161197

198+
{/* v0.17.0:精修产出偏好(默认档位/目标——发起时可临时覆盖;REQ-245) */}
199+
{(view || meta) && (
200+
<div style={{ border: "1px solid #e0e7ff", borderRadius: 8, padding: 10, marginBottom: 10, background: "#fbfaff" }}>
201+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
202+
<span style={{ fontWeight: 600, fontSize: 12.5 }}>精修产出偏好(默认)</span>
203+
<button
204+
style={{ ...btn, padding: "2px 10px", background: "#4f46e5", color: "#fff", border: "none", fontWeight: 600 }}
205+
disabled={strategyDraft == null}
206+
onClick={() => void saveStrategy()}
207+
>
208+
保存为默认
209+
</button>
210+
</div>
211+
<RefineStrategyPicker
212+
meta={meta}
213+
value={strategyDraft ?? { presetId: "", dims: {} }}
214+
onChange={setStrategyDraft}
215+
/>
216+
{msg?.kind === "ok" && <div style={{ color: "#0d9488", marginTop: 6 }}>{msg.text}</div>}
217+
</div>
218+
)}
219+
162220
{/* 授权确认卡(开启且未授权时出现) */}
163221
{consentVisible && (
164222
<div
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* NoteAiDialog — 笔记编辑态 AI 能力对话框(v0.17.0 REQ-246)。
3+
*
4+
* @ai-context: 统一入口(精修/知识补充/预留槽位)——知识补充板块从阅读态
5+
* 独立面板迁移至此(用户裁决:阅读态用 AI 直接进入编辑态)。
6+
* 精修=笔记级(内容=编辑器当前内容直接传参,未保存所见即所修;
7+
* profile=handwritten 笔记式/用户所选;基线=当前笔记版);
8+
* 补充=现有 EnrichPanel 交互(九子项勾选)内嵌复用。
9+
*/
10+
11+
import { useState } from "react";
12+
import EnrichPanel from "./EnrichPanel";
13+
import RefineLaunchDialog from "./RefineLaunchDialog";
14+
15+
const menuBtn: React.CSSProperties = {
16+
display: "block", width: "100%", textAlign: "left",
17+
padding: "10px 12px", cursor: "pointer", fontSize: 13, borderRadius: 8,
18+
border: "1px solid #e5e7eb", background: "#fff", color: "#374151", marginBottom: 6,
19+
};
20+
21+
export default function NoteAiDialog({
22+
noteId,
23+
noteContent,
24+
onClose,
25+
onUpdated,
26+
}: {
27+
noteId: number;
28+
/** 编辑器当前内容(发起精修时直接传参——未保存所见即所修) */
29+
noteContent: string;
30+
onClose: () => void;
31+
/** 知识补充完成回调(父层刷新笔记) */
32+
onUpdated?: (noteId: number) => void;
33+
}) {
34+
const [kind, setKind] = useState<"menu" | "refine" | "enrich">("menu");
35+
36+
return (
37+
<div
38+
style={{ position: "fixed", inset: 0, background: "rgba(17,24,39,.45)", zIndex: 1000,
39+
display: "flex", alignItems: "center", justifyContent: "center" }}
40+
onClick={() => { if (kind === "menu") onClose(); }}
41+
>
42+
<div
43+
style={{ width: 460, maxWidth: "92vw", maxHeight: "86vh", overflowY: "auto",
44+
background: "#fff", borderRadius: 12, padding: 16, boxShadow: "0 8px 30px rgba(0,0,0,.2)" }}
45+
onClick={(e) => e.stopPropagation()}
46+
>
47+
{kind === "menu" && (
48+
<>
49+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
50+
<span style={{ fontWeight: 600, fontSize: 14, color: "#374151" }}>🤖 AI 能力</span>
51+
<span style={{ fontSize: 11, color: "#9ca3af" }}>笔记 #{noteId}</span>
52+
</div>
53+
<button style={{ ...menuBtn, background: "#f5f3ff", border: "1px solid #c7d2fe" }} onClick={() => setKind("refine")}>
54+
<span style={{ fontWeight: 600 }}>✨ AI 精修</span>
55+
<div style={{ fontSize: 11, color: "#6b7280", marginTop: 2 }}>
56+
整理当前笔记(去冗余/结构化/按目标改写)——选择目标/变化程度,可实时预览提示词
57+
</div>
58+
</button>
59+
<button style={menuBtn} onClick={() => setKind("enrich")}>
60+
<span style={{ fontWeight: 600 }}>✧ 知识补充</span>
61+
<div style={{ fontSize: 11, color: "#6b7280", marginTop: 2 }}>
62+
补充与笔记相关的新知识(概念展开/步骤补全/例子/进阶/资源)——九子项勾选
63+
</div>
64+
</button>
65+
</>
66+
)}
67+
68+
{kind === "refine" && (
69+
<RefineLaunchDialog
70+
noteId={noteId}
71+
noteContent={noteContent}
72+
onClose={onClose}
73+
onStarted={(_targetId, _taskId) => {
74+
// 任务已入后台(事件/轮询双通道)——关闭对话框;父层保持编辑态
75+
onClose();
76+
}}
77+
/>
78+
)}
79+
80+
{kind === "enrich" && (
81+
<div>
82+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
83+
<span style={{ fontWeight: 600, fontSize: 14, color: "#374151" }}>✧ 知识补充</span>
84+
<button
85+
onClick={onClose}
86+
style={{ padding: "3px 10px", cursor: "pointer", fontSize: 12, border: "1px solid #d1d5db", borderRadius: 6, background: "#fff" }}
87+
>
88+
完成
89+
</button>
90+
</div>
91+
{/* 九子项面板复用(REQ-142 交互不变——仅入口迁至编辑态) */}
92+
<EnrichPanel noteId={noteId} onUpdated={() => onUpdated?.(noteId)} />
93+
</div>
94+
)}
95+
</div>
96+
</div>
97+
);
98+
}

‎app/src/components/NoteEditView.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ interface Props {
2525
}
2626

2727
/** v0.13.6(审查 H1 修复):父层命令式出口——ESC 出口先 await 保存再刷新
28-
* (原 ESC 由父层直接 setEditing(false),卸载保存与刷新竞态重演 P0) */
28+
* (原 ESC 由父层直接 setEditing(false),卸载保存与刷新竞态重演 P0)
29+
* v0.17.0:getContent——编辑器当前内容(AI 发起所见即所修,未保存传参) */
2930
export interface NoteEditHandle {
3031
flushSave: () => Promise<void>;
32+
getContent: () => string;
3133
}
3234

3335
// L14:自动保存双计时参数——停止输入 2s 存一次(debounce);dirty 期间最长
@@ -129,7 +131,11 @@ const NoteEditView = forwardRef<NoteEditHandle, Props>(function NoteEditView({ n
129131
}, [note.id]);
130132

131133
// 父层命令式出口(NotesPage ESC 出口先 await 再刷新)
132-
useImperativeHandle(ref, () => ({ flushSave: () => flushLatest() }), [flushLatest]);
134+
useImperativeHandle(
135+
ref,
136+
() => ({ flushSave: () => flushLatest(), getContent: () => contentRef.current }),
137+
[flushLatest],
138+
);
133139

134140
// Ctrl+S 显式保存(建版本)
135141
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {

0 commit comments

Comments
 (0)