mirror of
https://github.com/kuaifan/dootask.git
synced 2026-06-23 15:52:22 +00:00
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:
parent
e6ef85e176
commit
4de6c69972
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
*/
|
||||
|
||||
25
app/Models/AiAssistantFeedback.php
Normal file
25
app/Models/AiAssistantFeedback.php
Normal 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';
|
||||
}
|
||||
26
app/Models/AiAssistantSearchLog.php
Normal file
26
app/Models/AiAssistantSearchLog.php
Normal 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';
|
||||
}
|
||||
@ -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 启用 RAG(hint + search_help_docs tool),0 关闭
|
||||
'rag_enabled' => $ragEnabled ? '1' : '0',
|
||||
// 前端会话ID,AI 服务存为 context_key 用于检索打点关联
|
||||
'context_key' => mb_substr(trim((string)$contextKey), 0, 100),
|
||||
];
|
||||
|
||||
$baseUrl = trim((string)($setting[$modelType . '_base_url'] ?? ''));
|
||||
|
||||
@ -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('用户ID(token推导)');
|
||||
$table->bigInteger('dialog_id')->default(0)->comment('对话ID(chat流程;invoke流程为0)');
|
||||
$table->string('context_key', 191)->default('')->comment('上下文标识(chat=插件context_key;invoke=前端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');
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -996,3 +996,4 @@ LDAP 用户缺少邮箱属性,请联系管理员配置
|
||||
AI 助手
|
||||
没有查看权限
|
||||
当前仅指定人员可以创建项目
|
||||
反馈类型错误
|
||||
|
||||
@ -2454,3 +2454,16 @@ AI任务分析
|
||||
系统管理员(始终可创建,不受开关限制)。
|
||||
部门负责人与部门管理员。
|
||||
下方指定的人员。
|
||||
有帮助
|
||||
没帮助
|
||||
反馈失败,请重试
|
||||
带我去
|
||||
上一步
|
||||
下一步
|
||||
跳过引导
|
||||
正在生成操作引导…
|
||||
未找到目标元素,以下为操作说明
|
||||
引导已结束
|
||||
页面已切换,引导已结束
|
||||
步骤执行失败
|
||||
操作引导启动失败
|
||||
|
||||
@ -91,5 +91,8 @@
|
||||
"url": "https://www.dootask.com/api/download/update"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"dependencies": {
|
||||
"driver.js": "^1.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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` 列认领
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: 获取用户上传的会话图片(多模态用)
|
||||
|
||||
44
resources/ai-kb/zh/concept/ai-assistant/guide.md
Normal file
44
resources/ai-kb/zh/concept/ai-assistant/guide.md
Normal 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]]
|
||||
44
resources/ai-kb/zh/howto/ai-assistant/feedback.md
Normal file
44
resources/ai-kb/zh/howto/ai-assistant/feedback.md
Normal 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]]
|
||||
49
resources/ai-kb/zh/howto/ai-assistant/start-guide.md
Normal file
49
resources/ai-kb/zh/howto/ai-assistant/start-guide.md
Normal 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]]
|
||||
487
resources/assets/js/components/AIAssistant/guide/guide-renderer.js
vendored
Normal file
487
resources/assets/js/components/AIAssistant/guide/guide-renderer.js
vendored
Normal file
@ -0,0 +1,487 @@
|
||||
/**
|
||||
* AI 页面引导渲染器(单例状态机)
|
||||
*
|
||||
* 渲染层用 driver.js(高亮元素默认可点击 + 稳定定位 + 平滑过渡);
|
||||
* 编排层自研:脚本 schema、四级元素定位、跨页 pre_action 导航、找不到时降级。
|
||||
*
|
||||
* 两个入口汇聚到这里:
|
||||
* - 通道A:AI 回复中的 ```ai-guide 围栏脚本 → DialogMarkdown「带我去」按钮点击
|
||||
* - 通道B:AI 调 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 => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动引导。脚本无效时抛 Error(operation-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_elements,3s 超时)
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
47
resources/assets/js/components/AIAssistant/guide/guide.css
vendored
Normal file
47
resources/assets/js/components/AIAssistant/guide/guide.css
vendored
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取页面上下文
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
|
||||
// 检查元素本身
|
||||
|
||||
@ -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://')) {
|
||||
|
||||
32
resources/assets/js/utils/markdown.js
vendored
32
resources/assets/js/utils/markdown.js
vendored
@ -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 => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user