From 4de6c6997215beb393c7f0ff32915475175cc91c Mon Sep 17 00:00:00 2001 From: kuaifan Date: Wed, 10 Jun 2026 16:07:08 +0000 Subject: [PATCH] =?UTF-8?q?feat(ai-assistant):=20RAG=20=E5=8F=8D=E9=A6=88?= =?UTF-8?q?=E9=97=AD=E7=8E=AF=20+=20driver.js=20=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 方向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) --- .../Controllers/Api/AssistantController.php | 139 ++++- app/Models/AiAssistantFeedback.php | 25 + app/Models/AiAssistantSearchLog.php | 26 + app/Module/AI.php | 4 +- ..._create_ai_assistant_search_logs_table.php | 39 ++ ...02_create_ai_assistant_feedbacks_table.php | 36 ++ language/original-api.txt | 1 + language/original-web.txt | 13 + package.json | 5 +- resources/ai-kb/README.md | 40 ++ resources/ai-kb/_meta/feature-map.yaml | 3 + resources/ai-kb/_meta/tool-binding.yaml | 7 +- .../ai-kb/zh/concept/ai-assistant/guide.md | 44 ++ .../ai-kb/zh/howto/ai-assistant/feedback.md | 44 ++ .../zh/howto/ai-assistant/start-guide.md | 49 ++ .../AIAssistant/guide/guide-renderer.js | 487 ++++++++++++++++++ .../js/components/AIAssistant/guide/guide.css | 47 ++ .../js/components/AIAssistant/index.vue | 127 +++++ .../AIAssistant/operation-module.js | 13 + .../AIAssistant/page-context-collector.js | 6 +- .../manage/components/DialogMarkdown.vue | 17 + resources/assets/js/utils/markdown.js | 32 ++ .../components/dialog-markdown/markdown.less | 32 ++ 23 files changed, 1229 insertions(+), 7 deletions(-) create mode 100644 app/Models/AiAssistantFeedback.php create mode 100644 app/Models/AiAssistantSearchLog.php create mode 100644 database/migrations/2026_06_10_000001_create_ai_assistant_search_logs_table.php create mode 100644 database/migrations/2026_06_10_000002_create_ai_assistant_feedbacks_table.php create mode 100644 resources/ai-kb/zh/concept/ai-assistant/guide.md create mode 100644 resources/ai-kb/zh/howto/ai-assistant/feedback.md create mode 100644 resources/ai-kb/zh/howto/ai-assistant/start-guide.md create mode 100644 resources/assets/js/components/AIAssistant/guide/guide-renderer.js create mode 100644 resources/assets/js/components/AIAssistant/guide/guide.css diff --git a/app/Http/Controllers/Api/AssistantController.php b/app/Http/Controllers/Api/AssistantController.php index 2b49c5d5c..d3f0942dc 100644 --- a/app/Http/Controllers/Api/AssistantController.php +++ b/app/Http/Controllers/Api/AssistantController.php @@ -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, + ]); + } + /** * 获取会话列表 */ diff --git a/app/Models/AiAssistantFeedback.php b/app/Models/AiAssistantFeedback.php new file mode 100644 index 000000000..24c2d533f --- /dev/null +++ b/app/Models/AiAssistantFeedback.php @@ -0,0 +1,25 @@ + $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'] ?? '')); diff --git a/database/migrations/2026_06_10_000001_create_ai_assistant_search_logs_table.php b/database/migrations/2026_06_10_000001_create_ai_assistant_search_logs_table.php new file mode 100644 index 000000000..caa0636cd --- /dev/null +++ b/database/migrations/2026_06_10_000001_create_ai_assistant_search_logs_table.php @@ -0,0 +1,39 @@ +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'); + } +} diff --git a/database/migrations/2026_06_10_000002_create_ai_assistant_feedbacks_table.php b/database/migrations/2026_06_10_000002_create_ai_assistant_feedbacks_table.php new file mode 100644 index 000000000..98e2b24d0 --- /dev/null +++ b/database/migrations/2026_06_10_000002_create_ai_assistant_feedbacks_table.php @@ -0,0 +1,36 @@ +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'); + } +} diff --git a/language/original-api.txt b/language/original-api.txt index 0ddd1f67e..5585e35b9 100644 --- a/language/original-api.txt +++ b/language/original-api.txt @@ -996,3 +996,4 @@ LDAP 用户缺少邮箱属性,请联系管理员配置 AI 助手 没有查看权限 当前仅指定人员可以创建项目 +反馈类型错误 diff --git a/language/original-web.txt b/language/original-web.txt index 55daedf4d..12f7a14d5 100644 --- a/language/original-web.txt +++ b/language/original-web.txt @@ -2454,3 +2454,16 @@ AI任务分析 系统管理员(始终可创建,不受开关限制)。 部门负责人与部门管理员。 下方指定的人员。 +有帮助 +没帮助 +反馈失败,请重试 +带我去 +上一步 +下一步 +跳过引导 +正在生成操作引导… +未找到目标元素,以下为操作说明 +引导已结束 +页面已切换,引导已结束 +步骤执行失败 +操作引导启动失败 diff --git a/package.json b/package.json index 62a810336..67925cc24 100644 --- a/package.json +++ b/package.json @@ -91,5 +91,8 @@ "url": "https://www.dootask.com/api/download/update" } } - ] + ], + "dependencies": { + "driver.js": "^1.4.0" + } } diff --git a/resources/ai-kb/README.md b/resources/ai-kb/README.md index b7e57d667..b16738f63 100644 --- a/resources/ai-kb/README.md +++ b/resources/ai-kb/README.md @@ -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 = ''`。 + +**迭代流程**:每周跑口径 1/3 → 空结果高频 query 补新 chunk 或给现有 chunk 加 aliases → 👎 集中的 source 重写正文/negative → 提 PR → 容器重启 reconcile(或手动 `/kb/reindex`)→ 下周对比同 query 的 empty 率与 👎 数验证收效。 + ## 维护责任 - **内容**:产品功能负责人 / PM / 技术写作者按 `_meta/feature-map.yaml` 中的 `owner` 列认领 diff --git a/resources/ai-kb/_meta/feature-map.yaml b/resources/ai-kb/_meta/feature-map.yaml index 865cdedf0..77b751b29 100644 --- a/resources/ai-kb/_meta/feature-map.yaml +++ b/resources/ai-kb/_meta/feature-map.yaml @@ -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 diff --git a/resources/ai-kb/_meta/tool-binding.yaml b/resources/ai-kb/_meta/tool-binding.yaml index 79fb61b62..565cbddd3 100644 --- a/resources/ai-kb/_meta/tool-binding.yaml +++ b/resources/ai-kb/_meta/tool-binding.yaml @@ -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: 获取用户上传的会话图片(多模态用) diff --git a/resources/ai-kb/zh/concept/ai-assistant/guide.md b/resources/ai-kb/zh/concept/ai-assistant/guide.md new file mode 100644 index 000000000..f842cab86 --- /dev/null +++ b/resources/ai-kb/zh/concept/ai-assistant/guide.md @@ -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]] diff --git a/resources/ai-kb/zh/howto/ai-assistant/feedback.md b/resources/ai-kb/zh/howto/ai-assistant/feedback.md new file mode 100644 index 000000000..9bc23be32 --- /dev/null +++ b/resources/ai-kb/zh/howto/ai-assistant/feedback.md @@ -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]] diff --git a/resources/ai-kb/zh/howto/ai-assistant/start-guide.md b/resources/ai-kb/zh/howto/ai-assistant/start-guide.md new file mode 100644 index 000000000..5b5492252 --- /dev/null +++ b/resources/ai-kb/zh/howto/ai-assistant/start-guide.md @@ -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]] diff --git a/resources/assets/js/components/AIAssistant/guide/guide-renderer.js b/resources/assets/js/components/AIAssistant/guide/guide-renderer.js new file mode 100644 index 000000000..0d108050f --- /dev/null +++ b/resources/assets/js/components/AIAssistant/guide/guide-renderer.js @@ -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: `
${escapeHtml($A.L('正在生成操作引导…'))}
`, + 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 + ? `

${escapeHtml($A.L('未找到目标元素,以下为操作说明'))}

` + : ''; + + 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} + */ +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 }); +} diff --git a/resources/assets/js/components/AIAssistant/guide/guide.css b/resources/assets/js/components/AIAssistant/guide/guide.css new file mode 100644 index 000000000..29ecad13a --- /dev/null +++ b/resources/assets/js/components/AIAssistant/guide/guide.css @@ -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; +} diff --git a/resources/assets/js/components/AIAssistant/index.vue b/resources/assets/js/components/AIAssistant/index.vue index 19d4e55a8..d011970a4 100644 --- a/resources/assets/js/components/AIAssistant/index.vue +++ b/resources/assets/js/components/AIAssistant/index.vue @@ -139,6 +139,22 @@
{{ response.status === 'error' ? (response.error || $L('发送失败')) : $L('等待 AI 回复...') }}
+
+ + + + + + +
@@ -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; diff --git a/resources/assets/js/components/AIAssistant/operation-module.js b/resources/assets/js/components/AIAssistant/operation-module.js index 751ce072d..37bb9bea6 100644 --- a/resources/assets/js/components/AIAssistant/operation-module.js +++ b/resources/assets/js/components/AIAssistant/operation-module.js @@ -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 }; + } + /** * 获取页面上下文 */ diff --git a/resources/assets/js/components/AIAssistant/page-context-collector.js b/resources/assets/js/components/AIAssistant/page-context-collector.js index c21f4518c..4946a3666 100644 --- a/resources/assets/js/components/AIAssistant/page-context-collector.js +++ b/resources/assets/js/components/AIAssistant/page-context-collector.js @@ -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; // 检查元素本身 diff --git a/resources/assets/js/pages/manage/components/DialogMarkdown.vue b/resources/assets/js/pages/manage/components/DialogMarkdown.vue index 90547cf85..51b16c12a 100644 --- a/resources/assets/js/pages/manage/components/DialogMarkdown.vue +++ b/resources/assets/js/pages/manage/components/DialogMarkdown.vue @@ -5,6 +5,7 @@