feat(ai-assistant): RAG 反馈闭环 + driver.js 页面操作引导

方向7 反馈闭环:
- 新增 ai_assistant_search_logs / ai_assistant_feedbacks 两表 + 模型
- AssistantController 加 log__search / feedback__save 端点,auth 透传 session_id→context_key
- 前端 AI 回复下方 👍/👎(可改票、随会话持久化回显),extractSourceIds 解析引用
- ai-kb:feedback chunk + README 运营 SQL 口径(低分 top query / 👎 source)

方向4 页面操作引导:
- 渲染层用 driver.js(高亮可点击挖洞 + 稳定定位),编排层自研
  (脚本 schema / 四级元素定位 / 跨页 pre_action 导航 / 找不到降级文字)
- leave-semantics:跳转动作在点「下一步」后才执行
- markdown.js 渲染 ```ai-guide 围栏为「带我去」按钮;DialogMarkdown 点击启动
- 近义词归一化(创建/新建/新增/添加互通)提升 target 命中
- ai-kb:guide/start-guide chunk + tool-binding 加 show_guide

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-06-10 16:07:08 +00:00
parent e6ef85e176
commit 4de6c69972
23 changed files with 1229 additions and 7 deletions

View File

@ -2,6 +2,8 @@
namespace App\Http\Controllers\Api;
use App\Models\AiAssistantFeedback;
use App\Models\AiAssistantSearchLog;
use App\Models\AiAssistantSession;
use App\Models\User;
use App\Module\AI;
@ -33,6 +35,7 @@ class AssistantController extends AbstractController
* @apiParam {String} model_name 模型名称
* @apiParam {JSON} context 上下文数组
* @apiParam {String} [locale] ai-kb 检索语种zh、en缺省取请求语言 language包含 zh 视为 zh否则 en
* @apiParam {String} [session_id] 前端会话ID透传给 AI 服务作 context_key用于检索打点关联
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@ -49,11 +52,12 @@ class AssistantController extends AbstractController
$contextInput = Request::input('context', []);
$locale = trim(Request::input('locale', '')) ?: trim(Base::headerOrInput('language'));
$locale = str_contains(strtolower($locale), 'zh') ? 'zh' : 'en';
$contextKey = mb_substr(trim(Request::input('session_id', '')), 0, 100);
// 灰度判定(参考 config/ai.php总开关 + canary 白名单
$ragEnabled = AI::ragEnabledFor((int) $user->userid);
return AI::createStreamKey($modelType, $modelName, $contextInput, $locale, $ragEnabled);
return AI::createStreamKey($modelType, $modelName, $contextInput, $locale, $ragEnabled, $contextKey);
}
/**
@ -163,6 +167,139 @@ class AssistantController extends AbstractController
return $dotProduct / $denominator;
}
/**
* @api {post} api/assistant/log/search 记录帮助知识库检索日志
*
* @apiDescription 需要token身份AI 插件透传用户 token 服务端回调)。记录一次 search_help_docs 检索,用于分析检索质量、反哺 ai-kb 内容迭代
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName log__search
*
* @apiParam {String} query 检索query
* @apiParam {String} [locale] 语种 zh|en
* @apiParam {String} [source] 来源 chat|invoke
* @apiParam {String} [context_key] 上下文标识
* @apiParam {Number} [dialog_id] 对话ID
* @apiParam {Array} [source_ids] 命中source id列表
* @apiParam {Number} [top_score] 最高相似度
* @apiParam {Number} [result_count] 命中数量
* @apiParam {Number} [duration_ms] 检索耗时毫秒
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
*/
public function log__search()
{
$user = User::auth();
$query = mb_substr(trim(Request::input('query', '')), 0, 500);
$locale = trim(Request::input('locale', ''));
$source = trim(Request::input('source', ''));
$contextKey = mb_substr(trim(Request::input('context_key', '')), 0, 191);
$dialogId = intval(Request::input('dialog_id', 0));
$sourceIds = Request::input('source_ids', []);
$topScore = floatval(Request::input('top_score', 0));
$resultCount = intval(Request::input('result_count', 0));
$durationMs = intval(Request::input('duration_ms', 0));
if ($query === '') {
return Base::retError('参数错误');
}
if (!in_array($source, ['chat', 'invoke'])) {
$source = '';
}
if (!is_array($sourceIds)) {
$sourceIds = [];
}
$log = AiAssistantSearchLog::createInstance([
'userid' => $user->userid,
'dialog_id' => max(0, $dialogId),
'context_key' => $contextKey,
'source' => $source,
'query' => $query,
'locale' => in_array($locale, ['zh', 'en']) ? $locale : '',
'source_ids' => Base::array2json(array_slice(array_values($sourceIds), 0, 10)),
'top_score' => max(0, min(1, $topScore)),
'result_count' => max(0, $resultCount),
'duration_ms' => max(0, $durationMs),
'empty' => $resultCount > 0 ? 0 : 1,
]);
$log->save();
return Base::retSuccess('success');
}
/**
* @api {post} api/assistant/feedback/save 保存回复反馈
*
* @apiDescription 需要token身份。保存用户对一条 AI 回复的 👍/👎 反馈,同一条回复可改票(覆盖更新)
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName feedback__save
*
* @apiParam {String} session_key 场景分类key
* @apiParam {String} session_id 前端会话ID
* @apiParam {Number} local_id 回复条目localId
* @apiParam {String} feedback like|dislike
* @apiParam {String} [prompt] 用户问题
* @apiParam {String} [answer] 回复摘录
* @apiParam {Array} [source_ids] 回复引用的kb source id列表
* @apiParam {String} [model] 模型名
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {String} data.feedback 已保存的反馈值
*/
public function feedback__save()
{
$user = User::auth();
$sessionKey = mb_substr(trim(Request::input('session_key', 'default')), 0, 100);
$sessionId = mb_substr(trim(Request::input('session_id', '')), 0, 100);
$localId = intval(Request::input('local_id', 0));
$feedback = trim(Request::input('feedback', ''));
$prompt = mb_substr(trim(Request::input('prompt', '')), 0, 1000);
$answer = mb_substr(trim(Request::input('answer', '')), 0, 2000);
$sourceIds = Request::input('source_ids', []);
$model = mb_substr(trim(Request::input('model', '')), 0, 100);
if (empty($sessionId) || $localId <= 0) {
return Base::retError('参数错误');
}
if (!in_array($feedback, ['like', 'dislike'])) {
return Base::retError('反馈类型错误');
}
if (!is_array($sourceIds)) {
$sourceIds = [];
}
$exist = AiAssistantFeedback::where('userid', $user->userid)
->where('session_key', $sessionKey)
->where('session_id', $sessionId)
->where('local_id', $localId)
->first();
$row = AiAssistantFeedback::createInstance([
'userid' => $user->userid,
'session_key' => $sessionKey,
'session_id' => $sessionId,
'local_id' => $localId,
'feedback' => $feedback,
'prompt' => $prompt,
'answer' => $answer,
'answer_digest' => md5($answer),
'source_ids' => Base::array2json(array_slice(array_values($sourceIds), 0, 10)),
'model' => $model,
], $exist?->id);
$row->save();
return Base::retSuccess('success', [
'feedback' => $feedback,
]);
}
/**
* 获取会话列表
*/

View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
/**
* AI 助手回复反馈(👍/👎)
*
* @property int $id
* @property int $userid
* @property string $session_key
* @property string $session_id
* @property int $local_id
* @property string $feedback
* @property string|null $prompt
* @property string $answer_digest
* @property string|null $answer
* @property string|null $source_ids
* @property string $model
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class AiAssistantFeedback extends AbstractModel
{
protected $table = 'ai_assistant_feedbacks';
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
/**
* AI 助手帮助知识库检索日志
*
* @property int $id
* @property int $userid
* @property int $dialog_id
* @property string $context_key
* @property string $source
* @property string $query
* @property string $locale
* @property string|null $source_ids
* @property float $top_score
* @property int $result_count
* @property int $duration_ms
* @property int $empty
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class AiAssistantSearchLog extends AbstractModel
{
protected $table = 'ai_assistant_search_logs';
}

View File

@ -164,7 +164,7 @@ class AI
return in_array($userid, $allow, true);
}
public static function createStreamKey($modelType, $modelName, $contextInput = [], $locale = 'zh', $ragEnabled = true)
public static function createStreamKey($modelType, $modelName, $contextInput = [], $locale = 'zh', $ragEnabled = true, $contextKey = '')
{
$modelType = trim((string)$modelType);
$modelName = trim((string)$modelName);
@ -248,6 +248,8 @@ class AI
'locale' => $locale,
// ai-kb 灰度透传1 启用 RAGhint + search_help_docs tool0 关闭
'rag_enabled' => $ragEnabled ? '1' : '0',
// 前端会话IDAI 服务存为 context_key 用于检索打点关联
'context_key' => mb_substr(trim((string)$contextKey), 0, 100),
];
$baseUrl = trim((string)($setting[$modelType . '_base_url'] ?? ''));

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateAiAssistantSearchLogsTable extends Migration
{
public function up()
{
if (Schema::hasTable('ai_assistant_search_logs')) {
return;
}
Schema::create('ai_assistant_search_logs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('userid')->default(0)->comment('用户IDtoken推导');
$table->bigInteger('dialog_id')->default(0)->comment('对话IDchat流程invoke流程为0');
$table->string('context_key', 191)->default('')->comment('上下文标识chat=插件context_keyinvoke=前端session_id');
$table->string('source', 20)->default('')->comment('来源chat|invoke');
$table->string('query', 500)->default('')->comment('检索query截断500');
$table->string('locale', 10)->default('')->comment('语种 zh|en');
$table->text('source_ids')->nullable()->comment('命中source id列表 JSON');
$table->decimal('top_score', 6, 4)->default(0)->comment('最高相似度 0-1');
$table->integer('result_count')->default(0)->comment('命中数量');
$table->integer('duration_ms')->default(0)->comment('检索耗时毫秒');
$table->tinyInteger('empty')->default(0)->comment('是否空结果 0|1');
$table->timestamps();
$table->index('userid', 'idx_userid');
$table->index('context_key', 'idx_context_key');
$table->index(['empty', 'created_at'], 'idx_empty_created');
$table->index('created_at', 'idx_created_at');
});
}
public function down()
{
Schema::dropIfExists('ai_assistant_search_logs');
}
}

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateAiAssistantFeedbacksTable extends Migration
{
public function up()
{
if (Schema::hasTable('ai_assistant_feedbacks')) {
return;
}
Schema::create('ai_assistant_feedbacks', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('userid')->default(0)->comment('用户ID');
$table->string('session_key', 100)->default('')->comment('场景分类key同ai_assistant_sessions');
$table->string('session_id', 100)->default('')->comment('前端会话ID=检索日志context_key松关联');
$table->bigInteger('local_id')->default(0)->comment('前端回复条目localId');
$table->string('feedback', 10)->default('')->comment('like|dislike');
$table->text('prompt')->nullable()->comment('用户问题截断1000');
$table->string('answer_digest', 32)->default('')->comment('回复内容md5');
$table->text('answer')->nullable()->comment('回复摘录去reasoning截断2000');
$table->text('source_ids')->nullable()->comment('回复引用的kb source id列表 JSON');
$table->string('model', 100)->default('')->comment('模型名');
$table->timestamps();
$table->unique(['userid', 'session_key', 'session_id', 'local_id'], 'uk_user_entry');
$table->index(['feedback', 'created_at'], 'idx_feedback_created');
});
}
public function down()
{
Schema::dropIfExists('ai_assistant_feedbacks');
}
}

View File

@ -996,3 +996,4 @@ LDAP 用户缺少邮箱属性,请联系管理员配置
AI 助手
没有查看权限
当前仅指定人员可以创建项目
反馈类型错误

View File

@ -2454,3 +2454,16 @@ AI任务分析
系统管理员(始终可创建,不受开关限制)。
部门负责人与部门管理员。
下方指定的人员。
有帮助
没帮助
反馈失败,请重试
带我去
上一步
下一步
跳过引导
正在生成操作引导…
未找到目标元素,以下为操作说明
引导已结束
页面已切换,引导已结束
步骤执行失败
操作引导启动失败

View File

@ -91,5 +91,8 @@
"url": "https://www.dootask.com/api/download/update"
}
}
]
],
"dependencies": {
"driver.js": "^1.4.0"
}
}

View File

@ -71,6 +71,46 @@ curl -X POST 'http://ai-service/kb/reindex' \
-d '{"mode":"reconcile"}'
```
## 用检索打点与用户反馈数据迭代内容
主程序记录了两类质量数据mariadb表前缀以实际 `DB_PREFIX` 为准,下例用 `pre_`
- `pre_ai_assistant_search_logs` — 每次 `search_help_docs` 检索一行query、命中 source、分数、是否空结果
- `pre_ai_assistant_feedbacks` — 用户对 AI 回复的 👍/👎(含回复引用的 source id 列表)
**口径 1 —— 近 14 天低质量检索 top 问题(直接产出待补 chunk 清单):**
注意:向量 KNN 检索几乎总能返回 top-5胡乱提问也会命中分数偏低的近邻所以 `empty=1`
基本只在所查语种库为空时出现(如英文库未起草)。"知识库覆盖不到"的主信号是 **top_score 低**
```sql
SELECT query, locale, ROUND(AVG(top_score),3) AS avg_score, COUNT(*) AS cnt
FROM pre_ai_assistant_search_logs
WHERE (empty = 1 OR top_score < 0.8) AND created_at >= NOW() - INTERVAL 14 DAY
GROUP BY query, locale ORDER BY cnt DESC LIMIT 30;
```
**口径 2 —— 分数阈值校准(先看全局分布再定口径 1 的阈值):**
```sql
SELECT ROUND(top_score,1) AS bucket, COUNT(*) AS cnt
FROM pre_ai_assistant_search_logs
WHERE created_at >= NOW() - INTERVAL 14 DAY
GROUP BY bucket ORDER BY bucket;
```
**口径 3 —— 👎 最多的 source待修订清单**
```sql
SELECT jt.sid, COUNT(*) AS dislikes
FROM pre_ai_assistant_feedbacks f
JOIN JSON_TABLE(f.source_ids, '$[*]' COLUMNS (sid VARCHAR(191) PATH '$')) jt
WHERE f.feedback = 'dislike' AND f.created_at >= NOW() - INTERVAL 30 DAY
GROUP BY jt.sid ORDER BY dislikes DESC LIMIT 20;
```
钻取某条 👎 当时检索到了什么:`SELECT * FROM pre_ai_assistant_search_logs WHERE context_key = '<feedback.session_id>'`
**迭代流程**:每周跑口径 1/3 → 空结果高频 query 补新 chunk 或给现有 chunk 加 aliases → 👎 集中的 source 重写正文/negative → 提 PR → 容器重启 reconcile或手动 `/kb/reindex`)→ 下周对比同 query 的 empty 率与 👎 数验证收效。
## 维护责任
- **内容**:产品功能负责人 / PM / 技术写作者按 `_meta/feature-map.yaml` 中的 `owner` 列认领

View File

@ -823,8 +823,10 @@ features:
- ai-assistant.disabled.faq
- ai-assistant.element-action.howto
- ai-assistant.embed-entry.concept
- ai-assistant.feedback.howto
- ai-assistant.float-button.concept
- ai-assistant.float-button.howto
- ai-assistant.guide.concept
- ai-assistant.image-upload.howto
- ai-assistant.intelligent-search.howto
- ai-assistant.list-tasks.howto
@ -854,6 +856,7 @@ features:
- ai-assistant.session-save.howto
- ai-assistant.session.concept
- ai-assistant.shortcut.howto
- ai-assistant.start-guide.howto
- ai-assistant.stop.howto
- ai-assistant.streaming.concept
- ai-assistant.subtask-suggest.howto

View File

@ -9,7 +9,7 @@
# 加 helper/tools.py 中的内置工具GetSessionImageTool 等)
version: 1
last_updated: 2026-06-09
last_updated: 2026-06-10
tools:
@ -186,6 +186,11 @@ tools:
related_features: [ai-assistant]
typical_chunk_types: [howto]
show_guide:
description: 在用户页面启动分步操作引导(高亮元素+解说气泡,上一步/下一步推进)
related_features: [ai-assistant]
typical_chunk_types: [howto, concept]
# ===== 内置工具AI 插件 helper/tools.py=====
get_session_image:
description: 获取用户上传的会话图片(多模态用)

View File

@ -0,0 +1,44 @@
---
id: ai-assistant.guide.concept
title: 什么是页面操作引导
type: concept
feature: ai-assistant
scope: end-user
locale: zh
aliases:
- 操作引导
- 分步引导
- 带我去
- 新手引导
related_tools: [show_guide]
related_pages: []
prerequisites:
- 应用市场已安装 ai 插件
negative:
- 引导是解说式的,推进只靠气泡里的按钮,引导期间页面其他区域不可点击
- 引导进度不保存,刷新页面或切换路由即结束
last_verified: v1.7.90
---
# 什么是页面操作引导
## 这是什么
页面操作引导是 AI 助手的可视化教学能力AI 回答"X 怎么操作"后,可以在页面上分步高亮相关按钮/输入框,并配解说气泡,像导购一样带着你走完整个流程。
## 两种启动方式
1. **「带我去」按钮**AI 回答操作类问题时,回复末尾可能出现「带我去」按钮,点击即启动引导
2. **直接说**:对 AI 说"带我去操作"、"演示一下"AI 会直接在页面上启动引导
## 引导界面
- 半透明遮罩 + 高亮框圈出当前步骤的目标元素,自动滚动到可见位置
- 气泡显示步骤说明、当前进度(如 2 / 5和「上一步 / 下一步 / 跳过引导」按钮
- 找不到目标元素时(页面改版等),该步骤降级为纯文字说明,引导不中断
- 手机等窄屏设备上气泡固定在屏幕底部
## 不支持
- 不支持让用户亲手点击目标元素来推进步骤(需要的点击由引导代为执行)
- 不支持中断后恢复,跳过或刷新后需重新点「带我去」
## 相关
- 如何使用:[[ai-assistant.start-guide.howto]]
- AI 操作页面的底层能力:[[ai-assistant.page-action.concept]]

View File

@ -0,0 +1,44 @@
---
id: ai-assistant.feedback.howto
title: 给 AI 回答点赞或点踩
type: howto
feature: ai-assistant
scope: end-user
locale: zh
aliases:
- 点赞
- 点踩
- AI 回答不好怎么反馈
- 反馈 AI 回答
- 有帮助 没帮助
related_tools: []
related_pages: []
prerequisites:
- 应用市场已安装 ai 插件
negative:
- 反馈只针对 AI 助手浮窗里的回复,聊天对话里 @AI 机器人的消息暂不支持
- 点踩不会让 AI 立即重新回答,需要自己追问或重新提问
last_verified: v1.7.90
---
# 给 AI 回答点赞或点踩
## 这是什么
AI 助手浮窗中,每条 AI 回复完成后下方会出现「有帮助 / 没帮助」两个图标按钮(👍/👎)。点击即提交反馈,用于帮助官方改进 AI 回答质量和帮助文档内容。
## 怎么操作
1. 在 AI 助手浮窗中提问,等待回复完成(流式输出结束后按钮才出现)
2. 回复下方右侧点击 👍(有帮助)或 👎(没帮助)
3. 按钮高亮表示已提交;再点另一个按钮可以改票,同一条回复只记最新一次
## 反馈会被怎么用
- 反馈与该回复引用的帮助文档关联,被频繁点踩的文档会被优先修订
- 重新打开历史会话时,之前的反馈状态会保留显示
## 不支持
- 不支持填写文字原因,只有 👍/👎 两档
- 不支持取消反馈(可改票,不可清除)
## 相关
- AI 查帮助文档:[[ai-assistant.search-help-docs.howto]]
- 会话保存:[[ai-assistant.session-save.howto]]

View File

@ -0,0 +1,49 @@
---
id: ai-assistant.start-guide.howto
title: 让 AI 带我操作(启动页面引导)
type: howto
feature: ai-assistant
scope: end-user
locale: zh
aliases:
- 带我去
- 带我操作
- 一步步教我
- 演示给我看
- 怎么启动引导
related_tools: [show_guide]
related_pages: []
prerequisites:
- 应用市场已安装 ai 插件
negative:
- 通过聊天对话 @AI 机器人暂不支持页面引导,需在 AI 助手浮窗中使用
last_verified: v1.7.90
---
# 让 AI 带我操作(启动页面引导)
## 方式一:点「带我去」按钮
1. 打开 AI 助手浮窗(右下角悬浮按钮)
2. 问一个操作类问题,例如"项目里怎么创建任务"
3. AI 回答后,若回复末尾出现「带我去」按钮,点击即开始分步引导
## 方式二:直接让 AI 启动
在 AI 助手浮窗中直接说:
- "带我去创建一个任务"
- "演示一下怎么改看板列名"
- "一步步教我提交审批"
AI 会立即在页面上启动引导(浮窗自动收起,避免遮挡)。
## 引导中的操作
- **下一步 / 上一步**:在气泡里点击按钮推进或回看
- **跳过引导**:随时点气泡左下角的「跳过引导」结束
- 需要切换页面的步骤由引导自动跳转,无需手动操作
## 出现「未找到目标元素」怎么办
说明页面布局有变化或元素还没加载出来,该步骤会以纯文字说明显示,可以继续下一步;也可以跳过后把问题反馈给 AI点踩 👎)。
## 相关
- 引导是什么:[[ai-assistant.guide.concept]]
- 回答不好怎么反馈:[[ai-assistant.feedback.howto]]

View File

@ -0,0 +1,487 @@
/**
* AI 页面引导渲染器单例状态机
*
* 渲染层用 driver.js高亮元素默认可点击 + 稳定定位 + 平滑过渡
* 编排层自研脚本 schema四级元素定位跨页 pre_action 导航找不到时降级
*
* 两个入口汇聚到这里
* - 通道AAI 回复中的 ```ai-guide 围栏脚本 → DialogMarkdown「带我去」按钮点击
* - 通道BAI show_guide MCP 工具 operation-module WebSocket 请求
*
* 元素定位四级 fallback
* L1 selector精确 CSS L2 text可访问名称匹配 L3 query向量语义匹配
* L4 降级为居中纯文字气泡不中断引导
*/
import { driver } from 'driver.js';
import 'driver.js/dist/driver.css';
import './guide.css';
import { createActionExecutor } from '../action-executor';
import {
collectElements,
isElementVisible,
findElementByRef,
searchByVector,
} from '../page-context-collector';
import emitter from '../../../store/events';
// L3 向量匹配单次超时
const VECTOR_TIMEOUT = 3000;
// pre_action 后等待页面响应的固定延迟
const PRE_ACTION_DELAY = 300;
// pre_action 引发的路由变化宽限期
const NAV_GRACE_MS = 3000;
// 目标元素未显式指定 wait 时的默认等待窗口(导航/弹窗后晚渲染兜底)
const DEFAULT_WAIT = 1500;
const state = {
active: false,
script: null,
stepIndex: 0,
store: null,
router: null,
driver: null,
executor: null,
removeRouteHook: null,
navGraceUntil: 0,
// 自增运行序号:异步步骤解析期间引导被关闭/重启时丢弃旧结果
runSeq: 0,
};
function escapeHtml(s) {
return (s || '').replace(/[&<>"']/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
}
/**
* 校验并归一引导脚本宽容解析未知字段忽略 content 的步骤剔除
* @returns {{valid: boolean, script?: Object, error?: string}}
*/
export function validateScript(raw) {
let script = raw;
if (typeof raw === 'string') {
try {
script = JSON.parse(raw);
} catch (e) {
return { valid: false, error: 'JSON 解析失败' };
}
}
if (!script || typeof script !== 'object') {
return { valid: false, error: '脚本不是对象' };
}
if (script.version !== 1) {
return { valid: false, error: `不支持的脚本版本: ${script.version}` };
}
if (!Array.isArray(script.steps)) {
return { valid: false, error: '缺少 steps 数组' };
}
const steps = script.steps
.filter(s => s && typeof s === 'object' && typeof s.content === 'string' && s.content.trim())
.map(s => ({
title: typeof s.title === 'string' ? s.title : '',
content: s.content.trim(),
pre_action: s.pre_action && typeof s.pre_action === 'object' ? s.pre_action : null,
target: s.target && typeof s.target === 'object' ? s.target : null,
placement: typeof s.placement === 'string' ? s.placement : 'auto',
}));
if (!steps.length) {
return { valid: false, error: '没有有效步骤' };
}
return {
valid: true,
script: {
version: 1,
title: typeof script.title === 'string' ? script.title : '',
steps,
},
};
}
/**
* 启动引导脚本无效时抛 Erroroperation-module 据此回传 AI
*/
export function startGuide(raw, { store, router }) {
const check = validateScript(raw);
if (!check.valid) {
throw new Error(`引导脚本无效: ${check.error}`);
}
if (state.active) {
stopGuide({ silent: true });
}
state.active = true;
state.script = check.script;
state.stepIndex = 0;
state.store = store;
state.router = router;
state.executor = createActionExecutor(store, router);
state.runSeq++;
// 通知 AI 浮窗收起,避免遮挡目标元素
emitter.emit('aiGuideStarted');
createDriver();
watchRoute();
// 首步直接展示(不自动执行 pre_action需要跳转的动作等用户点「下一步」再执行
goToStep(0);
return { total_steps: check.script.steps.length };
}
/**
* 结束引导
*/
export function stopGuide({ silent = false } = {}) {
if (!state.active) {
return;
}
state.active = false;
state.runSeq++;
if (state.removeRouteHook) {
state.removeRouteHook();
state.removeRouteHook = null;
}
if (state.driver) {
try {
state.driver.destroy();
} catch (e) {
// ignore
}
state.driver = null;
}
state.script = null;
state.executor = null;
if (!silent) {
$A.messageSuccess('引导已结束');
}
}
function createDriver() {
// 深色模式DooTask 给 html 加全局 invert 滤镜,遮罩用浅色经反相后才呈暗色
const dark = typeof document !== 'undefined' && document.body.classList.contains('dark-mode-reverse');
state.driver = driver({
allowClose: false, // 不允许点遮罩误关,提供显式「跳过引导」
overlayColor: dark ? 'rgb(220, 220, 220)' : 'rgb(0, 0, 0)',
overlayOpacity: 0.5,
stagePadding: 6,
stageRadius: 6,
smoothScroll: true,
animate: true,
popoverClass: 'ai-guide-popover',
// disableActiveInteraction 默认 false → 高亮元素可点击(可点 + 下一步并存)
});
}
/**
* 加载态解析/导航期间显示居中提示
*/
function showLoading() {
if (!state.driver) {
return;
}
state.driver.highlight({
popover: {
description: `<div class="ai-guide-loading">${escapeHtml($A.L('正在生成操作引导…'))}</div>`,
showButtons: [],
popoverClass: 'ai-guide-popover',
},
});
}
function mapSide(placement) {
if (placement === 'top' || placement === 'bottom' || placement === 'left' || placement === 'right') {
return placement;
}
return undefined; // auto/center 交给 driver 自适应
}
/**
* 渲染某一步到 driver 气泡el 为空 居中纯文字
*/
function renderStep(index, el, degraded) {
if (!state.driver) {
return;
}
const total = state.script.steps.length;
const step = state.script.steps[index];
const isLast = index + 1 >= total;
const prefix = degraded
? `<p class="ai-guide-degraded">${escapeHtml($A.L('未找到目标元素,以下为操作说明'))}</p>`
: '';
state.driver.highlight({
element: el || undefined,
popover: {
title: step.title ? escapeHtml(step.title) : undefined,
description: prefix + escapeHtml(step.content),
side: mapSide(step.placement),
align: 'start',
showButtons: index > 0 ? ['next', 'previous'] : ['next'],
showProgress: total > 1,
progressText: `${index + 1} / ${total}`,
nextBtnText: isLast ? $A.L('完成') : $A.L('下一步'),
prevBtnText: $A.L('上一步'),
popoverClass: 'ai-guide-popover',
onNextClick: () => advance(),
onPrevClick: () => {
if (state.stepIndex > 0) {
goToStep(state.stepIndex - 1);
}
},
onPopoverRender: (popover) => addSkipButton(popover),
},
});
}
// 在气泡底部插入「跳过引导」按钮driver 默认按钮区无此项)
function addSkipButton(popover) {
if (!popover || !popover.footerButtons) {
return;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ai-guide-skip-btn';
btn.textContent = $A.L('跳过引导');
btn.addEventListener('click', () => stopGuide());
popover.footerButtons.insertBefore(btn, popover.footerButtons.firstChild);
}
function watchRoute() {
if (!state.router || typeof state.router.afterEach !== 'function') {
return;
}
state.removeRouteHook = state.router.afterEach(() => {
// pre_action 自己发起的导航在宽限期内豁免
if (!state.active || Date.now() < state.navGraceUntil) {
return;
}
stopGuide({ silent: true });
$A.messageInfo('页面已切换,引导已结束');
});
}
/**
* 下一步/完成先执行当前步 pre_action跳转/代点都在用户确认后才发生
* 再进入下一步leave-semantics最后一步执行完动作即结束
*/
async function advance() {
const seq = state.runSeq;
const cur = state.script.steps[state.stepIndex];
const isLast = state.stepIndex + 1 >= state.script.steps.length;
if (cur.pre_action) {
showLoading();
try {
await runPreAction(cur.pre_action);
} catch (e) {
console.warn('[AIGuide] pre_action failed:', e);
$A.messageWarning(e?.message || '步骤执行失败');
}
if (seq !== state.runSeq) return;
await delay(PRE_ACTION_DELAY);
if (seq !== state.runSeq) return;
}
if (isLast) {
stopGuide();
return;
}
goToStep(state.stepIndex + 1);
}
/**
* 展示某一步定位 target不执行任何动作target 找不到 降级为纯文字
*/
async function goToStep(index) {
const seq = state.runSeq;
const step = state.script.steps[index];
state.stepIndex = index;
showLoading();
let el = null;
let degraded = false;
try {
if (step.target) {
el = await resolveTarget(step.target);
if (seq !== state.runSeq) return;
if (!el) {
degraded = true;
}
}
} catch (e) {
if (seq !== state.runSeq) return;
console.warn('[AIGuide] resolve target failed, degrade to text:', e);
degraded = !!step.target;
el = null;
}
renderStep(index, el, degraded);
}
async function runPreAction(preAction) {
if (preAction.type === 'action' && preAction.name) {
state.navGraceUntil = Date.now() + NAV_GRACE_MS;
await state.executor.executeAction(preAction.name, preAction.params || {});
return;
}
if (preAction.type === 'click' && preAction.target) {
const el = await resolveTarget(preAction.target);
if (!el) {
throw new Error('未找到要点击的元素');
}
state.navGraceUntil = Date.now() + NAV_GRACE_MS;
el.click();
}
}
/**
* 四级元素定位wait>0 MutationObserver 等待动态元素
* @returns {Promise<Element|null>}
*/
async function resolveTarget(target) {
let el = resolveSync(target);
if (el) {
return el;
}
// L3 向量匹配(开销大,立即跑一次)
el = await resolveByVector(target);
if (el) {
return el;
}
// 默认给 1.5s 等待窗口:导航/弹窗后目标可能晚渲染,未显式指定也兜底重试
const wait = Math.min(Math.max(parseInt(target.wait, 10) || DEFAULT_WAIT, 0), 15000);
if (!wait) {
return null;
}
// 等待动态元素mutation 200ms 防抖只重跑廉价的 L1/L2超时前最后跑一次 L3
el = await new Promise(resolve => {
let timer = null;
let done = false;
const finish = (result) => {
if (done) return;
done = true;
observer.disconnect();
clearTimeout(timeoutTimer);
clearTimeout(timer);
resolve(result);
};
const observer = new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(() => {
const found = resolveSync(target);
if (found) {
finish(found);
}
}, 200);
});
observer.observe(document.body, { childList: true, subtree: true });
const timeoutTimer = setTimeout(() => finish(null), wait);
});
if (el) {
return el;
}
return resolveByVector(target);
}
// 归一化文本,吸收常见近义动词差异(创建/新建/新增/添加),提升标签匹配命中率
function _normText(s) {
return (s || '').toLowerCase().replace(/创建|新建|新增|添加/g, '建').replace(/\s+/g, '');
}
/**
* L1 selector L2 text同步廉价
*/
function resolveSync(target) {
// L1精确 CSS 选择器
if (target.selector) {
try {
const el = document.querySelector(target.selector);
if (el && isElementVisible(el)) {
return el;
}
} catch (e) {
// 选择器非法,忽略
}
}
// L2可访问名称匹配精确 → 归一双向包含)
if (target.text) {
const { elements, refMap } = collectElements({ maxElements: 500 });
const q = _normText(target.text);
const score = (info) => {
const name = _normText(info.name);
if (!name) return 0;
if (name === q) return 3;
if (name.includes(q)) return 2;
// 元素名较短且被 query 包含(如 query "创建项目卡片" 含元素 "新建项目"
if (name.length >= 2 && q.includes(name)) return 1;
return 0;
};
let best = null;
let bestScore = 0;
for (const info of elements) {
const s = score(info);
if (s > bestScore) {
const el = findElementByRef(info.ref, refMap);
if (el && isElementVisible(el)) {
best = el;
bestScore = s;
if (s === 3) break;
}
}
}
if (best) {
return best;
}
}
return null;
}
/**
* L3 query 向量语义匹配 assistant/match_elements3s 超时
*/
async function resolveByVector(target) {
if (!target.query || !state.store) {
return null;
}
try {
const { elements, refMap } = collectElements({ maxElements: 200 });
if (!elements.length) {
return null;
}
const matches = await Promise.race([
searchByVector(state.store, target.query, elements, 1),
delay(VECTOR_TIMEOUT).then(() => []),
]);
if (matches && matches.length) {
const el = findElementByRef(matches[0].ref, refMap);
if (el && isElementVisible(el)) {
return el;
}
}
} catch (e) {
// 向量匹配失败静默降级
}
return null;
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function isGuideActive() {
return state.active;
}
export default startGuide;
// 暴露到 window 供调试与 Playwright 测试使用
if (typeof window !== 'undefined') {
window.__startAiGuide = (script, ctx = {}) => {
let { store, router } = ctx;
if (!store || !router) {
const root = document.getElementById('app')?.__vue__;
store = store || root?.$store;
router = router || root?.$router;
}
return startGuide(script, { store, router });
};
window.__stopAiGuide = () => stopGuide({ silent: true });
}

View File

@ -0,0 +1,47 @@
/* AI 页面引导driver.js 气泡的项目内定制
* 说明DooTask 深色模式给 html 加全局 invert 滤镜白底气泡/深色文字会自动反相
* 故此处不单独写深色色值遮罩颜色已在 guide-renderer.js 按深色切换 */
.driver-popover.ai-guide-popover {
max-width: 340px;
border-radius: 10px;
font-size: 13px;
}
.driver-popover.ai-guide-popover .driver-popover-title {
font-size: 14px;
font-weight: 600;
}
.driver-popover.ai-guide-popover .driver-popover-description {
font-size: 13px;
line-height: 1.6;
}
.driver-popover.ai-guide-popover .ai-guide-degraded {
margin: 0 0 6px 0;
color: #e6a23c;
font-size: 12px;
}
.driver-popover.ai-guide-popover .ai-guide-loading {
padding: 6px 2px;
text-align: center;
color: #999;
}
/* 跳过引导:弱化为左侧链接式按钮 */
.driver-popover.ai-guide-popover .ai-guide-skip-btn {
margin-right: auto;
padding: 0 4px;
border: 0;
background: transparent;
color: #999;
font-size: 12px;
cursor: pointer;
text-shadow: none;
}
.driver-popover.ai-guide-popover .ai-guide-skip-btn:hover {
color: #666;
}

View File

@ -139,6 +139,22 @@
<div v-else class="ai-assistant-output-placeholder">
{{ response.status === 'error' ? (response.error || $L('发送失败')) : $L('等待 AI 回复...') }}
</div>
<div
v-if="response.rawOutput && response.status === 'completed'"
class="ai-assistant-output-feedback">
<span
:class="['ai-assistant-feedback-btn', {active: response.feedback === 'like'}]"
:title="$L('有帮助')"
@click="submitFeedback(response, 'like')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.633 10.25c.806 0 1.533-.446 2.031-1.08a9.041 9.041 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V3a.75.75 0 0 1 .75-.75 2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H13.48c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23H5.904m1.729-7.5a8.97 8.97 0 0 0-.621 4.72c.063.504.123 1.012.182 1.52.04.35.05.703.05 1.06v.27c0 .415-.336.75-.75.75h-2.25a.75.75 0 0 1-.75-.75v-7.5a.75.75 0 0 1 .75-.75h2.25c.414 0 .75.335.75.75v.198Z"/></svg>
</span>
<span
:class="['ai-assistant-feedback-btn ai-assistant-feedback-btn-down', {active: response.feedback === 'dislike'}]"
:title="$L('没帮助')"
@click="submitFeedback(response, 'dislike')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.633 10.25c.806 0 1.533-.446 2.031-1.08a9.041 9.041 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V3a.75.75 0 0 1 .75-.75 2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H13.48c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23H5.904m1.729-7.5a8.97 8.97 0 0 0-.621 4.72c.063.504.123 1.012.182 1.52.04.35.05.703.05 1.06v.27c0 .415-.336.75-.75.75h-2.25a.75.75 0 0 1-.75-.75v-7.5a.75.75 0 0 1 .75-.75h2.25c.414 0 .75.335.75.75v.198Z"/></svg>
</span>
</div>
</div>
</div>
<div v-else-if="displayMode === 'chat'" class="ai-assistant-welcome" @click="onFocus">
@ -343,6 +359,7 @@ export default {
},
mounted() {
emitter.on('openAIAssistant', this.onOpenAIAssistant);
emitter.on('aiGuideStarted', this.onGuideStarted);
this.loadCachedModel();
this.loadInputHistory();
this.mountFloatButton();
@ -350,6 +367,7 @@ export default {
},
beforeDestroy() {
emitter.off('openAIAssistant', this.onOpenAIAssistant);
emitter.off('aiGuideStarted', this.onGuideStarted);
this.clearActiveSSEClients();
this.clearAutoSubmitTimer();
this.unmountFloatButton();
@ -1011,6 +1029,8 @@ export default {
model_name,
context: JSON.stringify(context || []),
locale,
// ID AI context_key
session_id: this.currentSessionId || '',
};
const {data} = await this.$store.dispatch("call", {
url: 'assistant/auth',
@ -1306,6 +1326,7 @@ export default {
status: 'waiting',
error: '',
applyLoading: false,
feedback: '',
};
this.responses.push(entry);
if (this.responses.length > this.maxResponses) {
@ -1389,6 +1410,58 @@ export default {
return text.replace(/:::\s*reasoning[\s\S]*?:::/gi, '').trim();
},
/**
* 从回复末尾的引用行提取 ai-kb source id
* 主格式RAG hint 要求"参考howto.task-create, faq.xxx" / "References: ..."
* 同时兼容 LLM 偶发输出的 markdown 链接形态 "[标题](howto.task-create)"
*/
extractSourceIds(text) {
if (typeof text !== 'string' || !text) {
return [];
}
const cleaned = this.removeReasoningSections(text);
const m = cleaned.match(/(?:参考|References?)\s*[:]\s*(.+?)\s*$/im);
if (!m) {
return [];
}
const ids = m[1].match(/[a-z0-9][\w-]*(?:\.[a-z0-9][\w-]*)+/gi) || [];
return [...new Set(ids)].slice(0, 10);
},
/**
* 提交 👍/👎 反馈可改票点另一个值覆盖更新
*/
async submitFeedback(response, value) {
if (!response || response.feedbackLoading || response.feedback === value) {
return;
}
const prev = response.feedback;
this.$set(response, 'feedback', value);
this.$set(response, 'feedbackLoading', true);
try {
await this.$store.dispatch("call", {
url: 'assistant/feedback/save',
method: 'post',
data: {
session_key: this.currentSessionKey,
session_id: this.currentSessionId || '',
local_id: response.localId,
feedback: value,
prompt: this.parsePromptContent(response.prompt).text.substring(0, 1000),
answer: (this.removeReasoningSections(response.rawOutput) || '').substring(0, 2000),
source_ids: this.extractSourceIds(response.rawOutput),
model: response.model,
},
});
this.saveCurrentSession();
} catch (e) {
this.$set(response, 'feedback', prev);
$A.messageError(e?.msg || '反馈失败,请重试');
} finally {
this.$set(response, 'feedbackLoading', false);
}
},
/**
* 根据 onRender 回调生成展示文本
*/
@ -1433,6 +1506,15 @@ export default {
}, 300);
},
/**
* 页面引导启动时收起浮窗避免遮挡目标元素
*/
onGuideStarted() {
if (this.showModal) {
this.closeAssistant();
}
},
/**
* 滚动结果区域到底部
*/
@ -2632,6 +2714,51 @@ export default {
background: rgba(0, 0, 0, 0.02);
}
.ai-assistant-output-feedback {
margin-top: 6px;
display: flex;
justify-content: flex-end;
gap: 4px;
.ai-assistant-feedback-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 6px;
color: #999;
cursor: pointer;
transition: all 0.2s;
svg {
width: 15px;
height: 15px;
}
&:hover {
color: #666;
background: rgba(0, 0, 0, 0.05);
}
&.active {
color: var(--primary-color, #1677ff);
background: rgba(22, 119, 255, 0.08);
}
&.ai-assistant-feedback-btn-down {
svg {
transform: rotate(180deg);
}
&.active {
color: #f56c6c;
background: rgba(245, 108, 108, 0.08);
}
}
}
}
.ai-assistant-output-markdown {
margin-top: 12px;
font-size: 13px;

View File

@ -8,6 +8,7 @@
import { OperationClient } from './operation-client';
import { collectPageContext, searchByVector } from './page-context-collector';
import { createActionExecutor } from './action-executor';
import { startGuide } from './guide/guide-renderer';
/**
* 创建操作模块实例
@ -106,11 +107,23 @@ class OperationModule {
case 'execute_element_action':
return this.executeElementAction(payload);
case 'show_guide':
return this.showGuide(payload);
default:
throw new Error(`未知的操作类型: ${action}`);
}
}
/**
* 启动分步操作引导
* 校验失败 throw错误经 WS 回传给 AI成功立即返回不等引导走完避免 requestTimeout
*/
async showGuide(payload) {
const result = startGuide(payload, { store: this.store, router: this.router });
return { success: true, total_steps: result.total_steps };
}
/**
* 获取页面上下文
*/

View File

@ -234,7 +234,7 @@ function getAvailableActions(routeName, store) {
* @param {string} options.query - 搜索关键词
* @returns {Object} { elements, refMap, totalCount, hasMore, keywordMatched }
*/
function collectElements(options = {}) {
export function collectElements(options = {}) {
const {
interactiveOnly = false,
maxElements = 50,
@ -536,7 +536,7 @@ function getElementRole(el) {
* @param {Element} el
* @returns {string}
*/
function getElementName(el) {
export function getElementName(el) {
// 优先级aria-label > aria-labelledby > 内容文本 > title > placeholder > alt
const ariaLabel = el.getAttribute('aria-label');
@ -597,7 +597,7 @@ function getTextContent(el) {
* @param {Element} el
* @returns {boolean}
*/
function isElementVisible(el) {
export function isElementVisible(el) {
if (!el) return false;
// 检查元素本身

View File

@ -5,6 +5,7 @@
<script>
import '../../../../sass/pages/components/dialog-markdown/markdown.less'
import {MarkdownConver} from "../../../utils/markdown";
import {startGuide} from "../../../components/AIAssistant/guide/guide-renderer";
export default {
name: "DialogMarkdown",
@ -78,6 +79,22 @@ export default {
onCLick(e) {
const target = e.target;
// AI
const guideBtn = target.closest?.('.ai-guide-btn');
if (guideBtn) {
e.preventDefault();
e.stopPropagation();
const raw = guideBtn.getAttribute('data-guide');
if (raw) {
try {
this.beforeNavigate?.();
startGuide(decodeURIComponent(raw), {store: this.$store, router: this.$router});
} catch (err) {
$A.messageError(err?.message || '操作引导启动失败');
}
}
return;
}
if (target.tagName === 'A') {
const href = target.getAttribute('href');
if (href && href.startsWith('dootask://')) {

View File

@ -69,6 +69,29 @@ const MarkdownUtils = {
});
},
/**
* 渲染 ai-guide 围栏块AI 回复中嵌入的页面引导脚本
* JSON 合法 带我去按钮脚本存 data-guide点击由 DialogMarkdown 处理
* JSON 不合法流式中间态/畸形 灰色占位原文永不直出
* @param {string} content 围栏内容
* @returns {string}
*/
renderAiGuide: (content) => {
try {
const script = JSON.parse(content);
if (script && script.version === 1 && Array.isArray(script.steps) && script.steps.length > 0) {
const escaped = typeof script.title === 'string'
? script.title.replace(/[&<>"']/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]))
: '';
const title = escaped ? `${escaped} · ` : '';
return `<span class="ai-guide-block"><a href="javascript:;" class="ai-guide-btn" data-guide="${encodeURIComponent(content)}">${title}${$A.L('带我去')} →</a></span>`;
}
} catch (e) {
// 流式未闭合/JSON 畸形,走占位
}
return `<span class="ai-guide-pending">${$A.L('正在生成操作引导…')}</span>`;
},
/**
* 解析Markdown
* @param {*} text
@ -425,6 +448,15 @@ export function MarkdownConver(text) {
MarkdownUtils.mdi.use(mila, {attrs: {target: '_blank', rel: 'noopener noreferrer'}})
MarkdownUtils.mdi.use(mdKatex, {blockClass: 'katexmath-block rounded-md p-[10px]', errorColor: ' #cc0000'})
MarkdownPluginUtils.initReasoningPlugin(MarkdownUtils.mdi);
// ai-guide 围栏分流:未闭合围栏 markdown-it 照常产出 fence token天然覆盖流式中间态
const defaultFence = MarkdownUtils.mdi.renderer.rules.fence
|| ((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options));
MarkdownUtils.mdi.renderer.rules.fence = (tokens, idx, options, env, self) => {
if (tokens[idx].info.trim() === 'ai-guide') {
return MarkdownUtils.renderAiGuide(tokens[idx].content);
}
return defaultFence(tokens, idx, options, env, self);
};
}
text = MarkdownPluginUtils.clearEmptyReasoning(text);
text = mergeConsecutiveToolUse(text);

View File

@ -122,6 +122,38 @@ body {
margin-left: 8px;
color: #909399;
}
.ai-guide-block {
display: block;
margin: 8px 0;
.ai-guide-btn {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border-radius: 16px;
background: rgba(22, 119, 255, 0.08);
color: #1677ff;
text-decoration: none;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
&:hover {
background: rgba(22, 119, 255, 0.15);
}
}
}
.ai-guide-pending {
display: inline-block;
margin: 8px 0;
padding: 4px 10px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.04);
color: #999;
font-size: 12px;
}
}
.self {