feat: 笔记独立窗口改为多标签,AI 多轮会话与 TXT/MD 阅读
笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致: 标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增, 否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并 LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与 关窗都做二次确认,取消关闭必须回报主进程复位 closePending, 否则窗口再也关不掉而看门狗仍会销毁未保存内容。 AI 助手支持多轮会话:会话独立落盘,先取历史再写提问, 历史只发文本不重发图像,失败与取消都保留已流出的残片。 新增 TXT/MD 内置阅读(转内存 EPUB 复用 epub 渲染管线), 补上渲染层遗漏的可阅读格式白名单:主进程本就放行 txt/md, 但渲染层另有两份白名单漏了,表现为卡片上没有「阅读」按钮。 书库卡片封面改用 contain 完整显示,留白由同图模糊层垫底, 修正不同比例封面被裁切程度不一导致的观感不一致;多选复选框 去掉衬底色块,恢复原生外观。 其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、 GitHub Pages 站点。
This commit is contained in:
+93
-25
@@ -10,8 +10,8 @@ const { fetchWithProxy } = require('../sources/http');
|
||||
const MAX_CHARS = 12000;
|
||||
const MAX_QUESTION_CHARS = 4000;
|
||||
|
||||
// 上下文按字符数截断。中间挖空而不是尾部截断:
|
||||
// 结论性内容常在末尾,只留开头会让模型答非所问。
|
||||
// 中间挖空而不是尾部截断:结论性内容常在末尾,只留开头会让模型答非所问。
|
||||
// 正文默认不再走这里,只在有明确预算上限的场合(如多轮历史)显式调用。
|
||||
function clipContext(text, limit = MAX_CHARS) {
|
||||
const s = String(text || '');
|
||||
if (s.length <= limit) return s;
|
||||
@@ -42,7 +42,7 @@ const TASKS = {
|
||||
function buildPromptFromNormalized(task, text, question, visuals) {
|
||||
const t = TASKS[task];
|
||||
if (!t) throw new Error('不支持的任务类型: ' + task);
|
||||
let body = clipContext(text);
|
||||
let body = String(text || '');
|
||||
const ocr = visuals
|
||||
.filter((item) => item.ocr.include)
|
||||
.map((item) => item.ocr.text.trim())
|
||||
@@ -58,27 +58,60 @@ function buildPromptFromNormalized(task, text, question, visuals) {
|
||||
return { system, userText, images };
|
||||
}
|
||||
|
||||
function buildMessagesFromNormalized(task, text, question, visuals) {
|
||||
// 历史轮只取 role 与 text,其余字段(尤其 images)一律忽略:
|
||||
// 视觉模型按图块计费,重发历史图会让长会话费用随轮数累积,用户无从预期。
|
||||
function normalizeHistory(history) {
|
||||
if (!Array.isArray(history)) return [];
|
||||
const items = [];
|
||||
for (const entry of history) {
|
||||
if (!entry) continue;
|
||||
const role = entry.role === 'assistant' ? 'assistant' : 'user';
|
||||
const text = String(entry.text || '').trim();
|
||||
if (!text) continue;
|
||||
const last = items[items.length - 1];
|
||||
// 相邻同角色合并而不是丢弃:Anthropic 会直接 400,但丢内容比合并更糟
|
||||
if (last && last.role === role) last.text = `${last.text}\n\n${text}`;
|
||||
else items.push({ role, text });
|
||||
}
|
||||
// Anthropic 的 /messages 要求首条必须是 user
|
||||
while (items.length && items[0].role === 'assistant') items.shift();
|
||||
return items;
|
||||
}
|
||||
|
||||
// 当前轮固定是 user,历史末条若也是 user 就会相邻同角色,把它并入当前轮正文。
|
||||
function mergeHistory(history, currentText) {
|
||||
const items = normalizeHistory(history);
|
||||
const tail = items.length && items[items.length - 1].role === 'user' ? items.pop() : null;
|
||||
return {
|
||||
items,
|
||||
currentText: tail ? `${tail.text}\n\n${currentText}` : currentText
|
||||
};
|
||||
}
|
||||
|
||||
function buildMessagesFromNormalized(task, text, question, visuals, history) {
|
||||
const { system, userText, images } = buildPromptFromNormalized(task, text, question, visuals);
|
||||
const merged = mergeHistory(history, userText);
|
||||
const userContent = images.length
|
||||
? [
|
||||
{ type: 'text', text: userText },
|
||||
{ type: 'text', text: merged.currentText },
|
||||
...images.map((item) => ({
|
||||
type: 'image_url',
|
||||
image_url: { url: imageDataUrl(item.image) }
|
||||
}))
|
||||
]
|
||||
: userText;
|
||||
: merged.currentText;
|
||||
return [
|
||||
{ role: 'system', content: system },
|
||||
...merged.items.map((item) => ({ role: item.role, content: item.text })),
|
||||
{ role: 'user', content: userContent }
|
||||
];
|
||||
}
|
||||
|
||||
function buildAnthropicPayload(cfg, prompt) {
|
||||
function buildAnthropicPayload(cfg, prompt, history) {
|
||||
const merged = mergeHistory(history, prompt.userText);
|
||||
const content = prompt.images.length
|
||||
? [
|
||||
{ type: 'text', text: prompt.userText },
|
||||
{ type: 'text', text: merged.currentText },
|
||||
...prompt.images.map((item) => ({
|
||||
type: 'image',
|
||||
source: {
|
||||
@@ -88,20 +121,24 @@ function buildAnthropicPayload(cfg, prompt) {
|
||||
}
|
||||
}))
|
||||
]
|
||||
: prompt.userText;
|
||||
: merged.currentText;
|
||||
return {
|
||||
model: cfg.model,
|
||||
system: prompt.system,
|
||||
messages: [{ role: 'user', content }],
|
||||
messages: [
|
||||
...merged.items.map((item) => ({ role: item.role, content: item.text })),
|
||||
{ role: 'user', content }
|
||||
],
|
||||
temperature: cfg.temperature,
|
||||
max_tokens: cfg.maxTokens,
|
||||
stream: true
|
||||
};
|
||||
}
|
||||
|
||||
function buildResponsesPayload(cfg, prompt) {
|
||||
function buildResponsesPayload(cfg, prompt, history) {
|
||||
const merged = mergeHistory(history, prompt.userText);
|
||||
const content = [
|
||||
{ type: 'input_text', text: prompt.userText },
|
||||
{ type: 'input_text', text: merged.currentText },
|
||||
...prompt.images.map((item) => ({
|
||||
type: 'input_image',
|
||||
image_url: imageDataUrl(item.image)
|
||||
@@ -110,7 +147,12 @@ function buildResponsesPayload(cfg, prompt) {
|
||||
return {
|
||||
model: cfg.model,
|
||||
instructions: prompt.system,
|
||||
input: [{ role: 'user', content }],
|
||||
input: [
|
||||
// 纯字符串是 Responses 输入消息的合法简写,同时绕开 input_text/output_text
|
||||
// 的角色约束:input_text 不接受 assistant,output_text 只出现在带 id 的输出项里。
|
||||
...merged.items.map((item) => ({ role: item.role, content: item.text })),
|
||||
{ role: 'user', content }
|
||||
],
|
||||
temperature: cfg.temperature,
|
||||
max_output_tokens: cfg.maxTokens,
|
||||
stream: true,
|
||||
@@ -118,8 +160,14 @@ function buildResponsesPayload(cfg, prompt) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMessages(task, text, question, visualContexts) {
|
||||
return buildMessagesFromNormalized(task, text, question, normalizeVisualContexts(visualContexts));
|
||||
function buildMessages(task, text, question, visualContexts, history) {
|
||||
return buildMessagesFromNormalized(
|
||||
task,
|
||||
text,
|
||||
question,
|
||||
normalizeVisualContexts(visualContexts),
|
||||
history
|
||||
);
|
||||
}
|
||||
|
||||
function endpointFor(baseUrl, protocol) {
|
||||
@@ -144,24 +192,42 @@ function headersFor(cfg) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
function payloadFor(cfg, task, text, question, visuals) {
|
||||
function payloadFor(cfg, task, text, question, visuals, history) {
|
||||
const prompt = buildPromptFromNormalized(task, text, question, visuals);
|
||||
if (cfg.protocol === 'anthropic') return buildAnthropicPayload(cfg, prompt);
|
||||
if (cfg.protocol === 'openai-responses') return buildResponsesPayload(cfg, prompt);
|
||||
if (cfg.protocol === 'anthropic') return buildAnthropicPayload(cfg, prompt, history);
|
||||
if (cfg.protocol === 'openai-responses') return buildResponsesPayload(cfg, prompt, history);
|
||||
return {
|
||||
model: cfg.model,
|
||||
messages: buildMessagesFromNormalized(task, text, question, visuals),
|
||||
messages: buildMessagesFromNormalized(task, text, question, visuals, history),
|
||||
temperature: cfg.temperature,
|
||||
max_tokens: cfg.maxTokens,
|
||||
stream: true
|
||||
};
|
||||
}
|
||||
|
||||
// 正文不再由本地截断,超出模型窗口时只能由接口报错。各家措辞不同,
|
||||
// 统一识别成一句可操作的中文提示,否则用户只会看到一串英文而不知道该缩小范围。
|
||||
const CONTEXT_OVERFLOW_RE = /context[_\s-]?length|context window|maximum context|too many tokens|prompt is too long|reduce the length|input length|exceeds? the (?:maximum|context)/i;
|
||||
|
||||
function isContextOverflow(message, status) {
|
||||
// 413 单看状态码就够:请求体过大只可能是上下文塞太多
|
||||
if (status === 413) return true;
|
||||
if (!CONTEXT_OVERFLOW_RE.test(String(message || ''))) return false;
|
||||
return status === undefined || status === 400 || status === 422;
|
||||
}
|
||||
|
||||
function overflowHint(message) {
|
||||
return `上下文超出模型窗口,请把范围改小(如改用"当前页"或选中片段)或换用更大窗口的模型。接口原文:${message}`;
|
||||
}
|
||||
|
||||
function parseErrorBody(text, status) {
|
||||
try {
|
||||
const j = JSON.parse(text);
|
||||
const msg = (j.error && (j.error.message || j.error)) || j.message;
|
||||
if (msg) return String(msg);
|
||||
if (msg) {
|
||||
const s = String(msg);
|
||||
return isContextOverflow(s, status) ? overflowHint(s) : s;
|
||||
}
|
||||
} catch (e) { /* 非 JSON */ }
|
||||
if (status === 401 || status === 403) return 'API Key 无效或没有权限';
|
||||
if (status === 404) return '接口地址或模型名称不存在';
|
||||
@@ -189,8 +255,8 @@ function streamFinished(protocol, event) {
|
||||
}
|
||||
|
||||
// onDelta 每收到一段增量就回调一次;返回完整文本。
|
||||
// signal 用于用户中途取消。
|
||||
async function stream({ task, text, question, visualContexts, signal, onDelta }) {
|
||||
// signal 用于用户中途取消。history 是本轮之前的历史轮次,只消费 role 与 text。
|
||||
async function stream({ task, text, question, visualContexts, history, signal, onDelta }) {
|
||||
const cfg = aiConfig.get();
|
||||
const st = aiConfig.status();
|
||||
if (!cfg.apiKey && !st.isLocal) throw new Error('尚未配置 API Key,请先在设置中填写');
|
||||
@@ -203,7 +269,7 @@ async function stream({ task, text, question, visualContexts, signal, onDelta })
|
||||
const res = await fetchWithProxy(endpointFor(cfg.baseUrl, cfg.protocol), {
|
||||
method: 'POST',
|
||||
headers: headersFor(cfg),
|
||||
body: JSON.stringify(payloadFor(cfg, task, text, question, visuals)),
|
||||
body: JSON.stringify(payloadFor(cfg, task, text, question, visuals, history)),
|
||||
signal
|
||||
});
|
||||
|
||||
@@ -231,11 +297,13 @@ async function stream({ task, text, question, visualContexts, signal, onDelta })
|
||||
// 部分服务端把错误放在流里返回
|
||||
if (j.error || j.type === 'error') {
|
||||
const error = j.error || j;
|
||||
throw new Error(error.message || String(error));
|
||||
const message = error.message || String(error);
|
||||
throw new Error(isContextOverflow(message) ? overflowHint(message) : message);
|
||||
}
|
||||
if (cfg.protocol === 'openai-responses' && ['response.failed', 'response.incomplete'].includes(j.type)) {
|
||||
const error = j.response && (j.response.error || j.response.incomplete_details);
|
||||
throw new Error((error && (error.message || error.reason)) || 'OpenAI Responses 请求未完成');
|
||||
const message = (error && (error.message || error.reason)) || 'OpenAI Responses 请求未完成';
|
||||
throw new Error(isContextOverflow(message) ? overflowHint(message) : message);
|
||||
}
|
||||
const piece = streamDelta(cfg.protocol, j);
|
||||
if (piece) {
|
||||
|
||||
Reference in New Issue
Block a user