Skip to content

Commit 2ad5043

Browse files
committed
Version 1.0.4 is expected to be released.
1 parent a7fd1fa commit 2ad5043

15 files changed

Lines changed: 412 additions & 99 deletions

how_to_use.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# 怎么使用RemindAI
22

3+
- 对话模式下新建一个纯对话,会保持上一个工作目录,需要手动在Clip栏取消
34
- 纯对话模式下打开一个工作目录,可以切换到工作目录模式
45
- 工作目录下手动添加memory.json文件可以手动修改权限
56
- 新建工作目录可以与指定工作目录同名,这样在工作目录下面添加memory.json

lib/core/llm/anthropic_client.dart

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,8 +404,15 @@ class AnthropicClient implements LlmClient {
404404
}
405405
}
406406

407-
// 流未显式 message_stop 也兜底输出
408-
yield _complete(contentBuf, toolBlocks, finishReason);
407+
// 流未显式 message_stop:返回截断标记,上层不得当作正常完成。
408+
final fallback = _complete(contentBuf, toolBlocks, finishReason);
409+
yield StreamComplete(
410+
content: fallback.content,
411+
reasoningContent: fallback.reasoningContent,
412+
toolCalls: fallback.toolCalls,
413+
finishReason: 'stream_incomplete',
414+
isTruncated: true,
415+
);
409416
}
410417

411418
StreamComplete _complete(

lib/core/llm/gemini_client.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ class GeminiClient implements LlmClient {
334334
String buffer = '';
335335
final contentBuf = StringBuffer();
336336
final calls = <ToolCall>[];
337-
String finishReason = 'stop';
337+
String? finishReason;
338338

339339
await for (final chunk in rawStream) {
340340
buffer += utf8.decode(chunk, allowMalformed: true);
@@ -386,7 +386,8 @@ class GeminiClient implements LlmClient {
386386
yield StreamComplete(
387387
content: contentBuf.isEmpty ? null : contentBuf.toString(),
388388
toolCalls: calls.isEmpty ? null : calls,
389-
finishReason: finishReason,
389+
finishReason: finishReason ?? 'stream_incomplete',
390+
isTruncated: finishReason == null,
390391
);
391392
}
392393
}

lib/core/llm/llm_client.dart

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,18 @@ class StreamComplete extends StreamEvent {
2929
final String? reasoningContent;
3030
final List<ToolCall>? toolCalls;
3131
final String finishReason;
32+
33+
/// 流是否在协议结束标记前异常中断。
34+
///
35+
/// true 表示仅收到了部分数据,不能把本次响应当作正常完成。
36+
final bool isTruncated;
37+
3238
StreamComplete({
3339
this.content,
3440
this.reasoningContent,
3541
this.toolCalls,
3642
required this.finishReason,
43+
this.isTruncated = false,
3744
});
3845

3946
/// 转换为 assistant 消息 JSON (用于追加到 messages 历史)
@@ -413,7 +420,7 @@ class OpenAiClient implements LlmClient {
413420
final contentBuf = StringBuffer();
414421
final reasoningBuf = StringBuffer();
415422
final toolCallsMap = <int, _ToolCallAccumulator>{}; // index → accumulator
416-
String finishReason = 'stop';
423+
String? finishReason;
417424
int chunkCount = 0;
418425
int tokenCount = 0;
419426
bool firstChunk = true;
@@ -462,7 +469,7 @@ class OpenAiClient implements LlmClient {
462469
? null
463470
: reasoningBuf.toString(),
464471
toolCalls: calls,
465-
finishReason: finishReason,
472+
finishReason: finishReason ?? 'stop',
466473
);
467474
return;
468475
}
@@ -554,7 +561,8 @@ class OpenAiClient implements LlmClient {
554561
content: contentBuf.isEmpty ? null : contentBuf.toString(),
555562
reasoningContent: reasoningBuf.isEmpty ? null : reasoningBuf.toString(),
556563
toolCalls: calls,
557-
finishReason: finishReason,
564+
finishReason: finishReason ?? 'stream_incomplete',
565+
isTruncated: true,
558566
);
559567
}
560568
}

lib/core/server/api_server.dart

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,8 @@ class ApiServer {
334334
String? errMsg;
335335
await for (final ev in events) {
336336
switch (ev) {
337+
case AgentReasoningToken():
338+
break;
337339
case AgentToken(text: final t):
338340
buf.write(t);
339341
case AgentDone(content: final c):
@@ -402,7 +404,9 @@ class ApiServer {
402404
try {
403405
await for (final ev in events) {
404406
switch (ev) {
405-
case AgentToken(text: final t):
407+
case AgentReasoningToken():
408+
break;
409+
case AgentToken(text: final t):
406410
sendChunk({'content': t});
407411
case AgentToolStart(name: final n, args: final a):
408412
if (exposeTools) {
@@ -529,7 +533,9 @@ class ApiServer {
529533
try {
530534
await for (final ev in events) {
531535
switch (ev) {
532-
case AgentToken(text: final t):
536+
case AgentReasoningToken():
537+
break;
538+
case AgentToken(text: final t):
533539
buf.write(t);
534540
case AgentDone(content: final c):
535541
if (buf.isEmpty && c.isNotEmpty) buf.write(c);
@@ -615,7 +621,9 @@ class ApiServer {
615621
try {
616622
await for (final ev in events) {
617623
switch (ev) {
618-
case AgentToken(text: final t):
624+
case AgentReasoningToken():
625+
break;
626+
case AgentToken(text: final t):
619627
sendEvent('content_block_delta', {
620628
'type': 'content_block_delta',
621629
'index': 0,

lib/core/toolshell/agent_loop.dart

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ class AgentToken extends AgentEvent {
8989
AgentToken(this.text);
9090
}
9191

92+
/// 模型内部推理 token。与最终正文分开,避免 reasoning-only 响应被当作
93+
/// 已完成的 assistant 正文写入历史。
94+
class AgentReasoningToken extends AgentEvent {
95+
final String text;
96+
AgentReasoningToken(this.text);
97+
}
98+
9299
class AgentToolStart extends AgentEvent {
93100
final String name;
94101
final Map<String, dynamic> args;
@@ -197,8 +204,8 @@ class AgentLoop {
197204
// 实时推送 token 给 UI
198205
yield AgentToken(text);
199206
case ReasoningToken(text: final text):
200-
// 推理/思考 token (DeepSeek等) — 同样推送给 UI 展示
201-
yield AgentToken(text);
207+
// 推理 token 与最终正文分开传递;不能作为 AgentDone 的正文兜底。
208+
yield AgentReasoningToken(text);
202209
case StreamComplete():
203210
completed = event;
204211
}
@@ -219,6 +226,46 @@ class AgentLoop {
219226
return;
220227
}
221228

229+
// 截断/异常流不能伪装成正常完成。尤其推理模型可能只收到 reasoning,
230+
// 连接便被代理关闭;此时必须报告错误,不能写入空 assistant 消息。
231+
if (completed.isTruncated) {
232+
yield AgentError(
233+
'LLM 响应被截断(finish_reason=${completed.finishReason})。'
234+
'模型尚未完成最终输出,请重试或检查网络、代理超时及上下文长度。',
235+
);
236+
return;
237+
}
238+
239+
final finishReason = completed.finishReason.toLowerCase();
240+
const abnormalFinishReasons = {
241+
'length',
242+
'max_tokens',
243+
'content_filter',
244+
'error',
245+
'cancelled',
246+
'stream_incomplete',
247+
};
248+
if (abnormalFinishReasons.contains(finishReason)) {
249+
yield AgentError(
250+
'LLM 未正常完成响应(finish_reason=${completed.finishReason})。'
251+
'请增加输出上限、缩短上下文或重试。',
252+
);
253+
return;
254+
}
255+
256+
final hasToolCalls =
257+
completed.toolCalls != null && completed.toolCalls!.isNotEmpty;
258+
final hasFinalContent = completed.content?.trim().isNotEmpty ?? false;
259+
final hasReasoning =
260+
completed.reasoningContent?.trim().isNotEmpty ?? false;
261+
if (!hasToolCalls && !hasFinalContent && hasReasoning) {
262+
yield AgentError(
263+
'模型只返回了推理内容,没有生成最终回答。'
264+
'这通常表示输出上限不足或流被提前终止,请重试。',
265+
);
266+
return;
267+
}
268+
222269
// ─── DSML 兼容层:解析 DeepSeek 等模型的文本内嵌工具调用 ───
223270
if (completed.content != null &&
224271
completed.content!.isNotEmpty &&

lib/core/toolshell/autonomous_loop.dart

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ class AutonomousLoop {
121121
_injectLoopInstruction();
122122

123123
String? lastOutput;
124+
var consecutiveStalledRounds = 0;
124125

125126
for (var iteration = 1; iteration <= config.maxIterations; iteration++) {
126127
yield LoopIterStart(iteration, config.maxIterations);
@@ -153,6 +154,9 @@ class AutonomousLoop {
153154
yield LoopAgentEvent(event);
154155

155156
switch (event) {
157+
case AgentReasoningToken():
158+
// 推理过程仅透传给 UI,不计入本轮最终输出和停滞判断。
159+
break;
156160
case AgentToken(text: final text):
157161
buffer.write(text);
158162
case AgentToolStart(name: _, args: _):
@@ -201,11 +205,19 @@ class AutonomousLoop {
201205
return;
202206
}
203207

204-
// 无进展检测:连续两轮输出高度相似
205-
if (previousOutput != null && _isStalledOutput(previousOutput, output)) {
206-
AppLogger.instance.log('[Loop] 检测到无进展,终止');
207-
yield LoopStalled(iteration);
208-
return;
208+
// 无进展检测:至少完成 3 轮,并且连续两次检测均高度相似才停止。
209+
// 避免第 2 轮因固定的“目标/操作/验证/结果”模板而被误判。
210+
if (iteration >= 3 &&
211+
previousOutput != null &&
212+
_isStalledOutput(previousOutput, output)) {
213+
consecutiveStalledRounds++;
214+
if (consecutiveStalledRounds >= 2) {
215+
AppLogger.instance.log('[Loop] 连续两轮检测到无实质进展,终止');
216+
yield LoopStalled(iteration);
217+
return;
218+
}
219+
} else {
220+
consecutiveStalledRounds = 0;
209221
}
210222

211223
if (hasError) return;
@@ -235,8 +247,17 @@ class AutonomousLoop {
235247
String _buildContinuePrompt(int iteration, String? lastOutput) {
236248
final buffer = StringBuffer();
237249
buffer.writeln('[Loop 第 $iteration 轮 — 请继续]');
238-
buffer.writeln('上一轮你的操作已执行完毕。请检查结果并继续推进任务。');
239-
buffer.writeln('如果任务已完成,请输出 [LOOP_DONE] + 总结。');
250+
buffer.writeln('上一轮尚未声明任务完成。请基于已执行的工具结果继续推进,');
251+
buffer.writeln('不要重复上一轮的说明或原样重做已经成功的步骤。');
252+
if (lastOutput != null && lastOutput.trim().isNotEmpty) {
253+
final compact = lastOutput.length > 1200
254+
? lastOutput.substring(lastOutput.length - 1200)
255+
: lastOutput;
256+
buffer.writeln('\n上一轮最终输出摘要(仅供识别未完成项):');
257+
buffer.writeln(compact);
258+
}
259+
buffer.writeln('\n请优先执行尚未完成的下一步,并验证实际结果。');
260+
buffer.writeln('只有任务整体完成且验证通过时,才输出 [LOOP_DONE] + 总结。');
240261
buffer.writeln('如果需要继续,请直接执行下一步操作。');
241262
return buffer.toString();
242263
}

lib/core/toolshell/sub_readers_orchestrator.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,8 @@ class SubReadersOrchestrator {
283283
return content.isNotEmpty ? content : buffer.toString();
284284
case AgentError(message: final msg):
285285
throw Exception(msg);
286+
case AgentReasoningToken():
287+
break;
286288
case AgentToken(text: final text):
287289
buffer.write(text);
288290
case AgentToolStart():

lib/core/utils/directory_picker.dart

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,52 @@
11
import 'dart:async';
2+
import 'dart:io';
23

34
import 'package:file_selector/file_selector.dart' as fs;
45

6+
import '../logger/app_logger.dart';
7+
8+
/// 异步检查目录是否存在,并限制等待时间。
9+
///
10+
/// 不使用 [Directory.existsSync],避免离线网络盘、云盘占位目录或失效
11+
/// Junction 在 Flutter UI isolate 上同步阻塞。
12+
Future<bool> directoryExistsSafely(
13+
String path, {
14+
Duration timeout = const Duration(seconds: 2),
15+
}) async {
16+
if (path.trim().isEmpty) return false;
17+
final stopwatch = Stopwatch()..start();
18+
try {
19+
final exists = await Directory(path).exists().timeout(timeout);
20+
stopwatch.stop();
21+
AppLogger.instance.log(
22+
'[DirectoryPicker] 路径检查完成: exists=$exists, '
23+
'elapsed=${stopwatch.elapsedMilliseconds}ms, path=$path',
24+
);
25+
return exists;
26+
} on TimeoutException {
27+
stopwatch.stop();
28+
AppLogger.instance.log(
29+
'[DirectoryPicker] 路径检查超时: '
30+
'elapsed=${stopwatch.elapsedMilliseconds}ms, path=$path',
31+
);
32+
return false;
33+
} catch (e) {
34+
stopwatch.stop();
35+
AppLogger.instance.log(
36+
'[DirectoryPicker] 路径检查异常: $e, '
37+
'elapsed=${stopwatch.elapsedMilliseconds}ms, path=$path',
38+
);
39+
return false;
40+
}
41+
}
42+
43+
/// 返回可安全传给原生选择器的初始目录。
44+
/// 无效或慢路径返回 null,避免 Windows 恢复到失效网络位置。
45+
Future<String?> validInitialDirectory(String? candidate) async {
46+
if (candidate == null || candidate.trim().isEmpty) return null;
47+
return await directoryExistsSafely(candidate) ? candidate : null;
48+
}
49+
550
/// 统一的目录选择封装。
651
///
752
/// 历史问题:`file_picker` 的目录选择(底层 comdlg32)在 Windows 上会**原生崩溃**,
@@ -19,16 +64,43 @@ Future<String?> pickDirectory({
1964
String? initialDirectory,
2065
Duration timeout = const Duration(seconds: 60),
2166
}) async {
67+
final stopwatch = Stopwatch()..start();
68+
AppLogger.instance.log(
69+
'[DirectoryPicker] 开始调用原生选择器: '
70+
'initial=${initialDirectory ?? "(系统默认)"}, timeout=${timeout.inSeconds}s',
71+
);
2272
try {
2373
final path = await fs
2474
.getDirectoryPath(initialDirectory: initialDirectory)
2575
.timeout(timeout);
76+
stopwatch.stop();
77+
if (path == null) {
78+
AppLogger.instance.log(
79+
'[DirectoryPicker] 用户取消选择: elapsed=${stopwatch.elapsedMilliseconds}ms',
80+
);
81+
} else {
82+
AppLogger.instance.log(
83+
'[DirectoryPicker] 原生选择器返回: '
84+
'elapsed=${stopwatch.elapsedMilliseconds}ms, path=$path',
85+
);
86+
}
2687
return path;
2788
} on TimeoutException {
28-
// 原生对话框卡死的兜底,静默忽略
89+
stopwatch.stop();
90+
// 注意:Dart Future 超时无法强制关闭已卡住的 Windows IFileDialog。
91+
AppLogger.instance.log(
92+
'[DirectoryPicker] Dart 等待超时: '
93+
'elapsed=${stopwatch.elapsedMilliseconds}ms。'
94+
'原生 IFileDialog 可能仍在阻塞平台线程。',
95+
);
2996
return null;
30-
} catch (_) {
31-
// 其他平台异常,静默忽略
97+
} catch (e, stackTrace) {
98+
stopwatch.stop();
99+
AppLogger.instance.log(
100+
'[DirectoryPicker] 原生选择器异常: $e, '
101+
'elapsed=${stopwatch.elapsedMilliseconds}ms',
102+
);
103+
AppLogger.instance.log('[DirectoryPicker] StackTrace: $stackTrace');
32104
return null;
33105
}
34106
}

0 commit comments

Comments
 (0)