From 35c63802b5ecfce67ff554967c8558b1f2510376 Mon Sep 17 00:00:00 2001 From: Roe-xin Date: Tue, 3 Mar 2026 16:45:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=B7=BB=E5=8A=A0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E6=A0=87=E7=AD=BE=E6=98=BE=E7=A4=BA=E5=92=8C?= =?UTF-8?q?rules=E9=9C=80=E6=B1=82=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/personal-rules-mvp-requirements.md | 161 ++++++++++++++++++++++++ src/panels/ICHelperPanel.ts | 17 +++ src/views/filePathTag.ts | 62 +++++++++ src/views/inputArea.ts | 20 ++- src/views/messageArea.ts | 21 +++- 5 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 docs/personal-rules-mvp-requirements.md create mode 100644 src/views/filePathTag.ts diff --git a/docs/personal-rules-mvp-requirements.md b/docs/personal-rules-mvp-requirements.md new file mode 100644 index 0000000..2fefd5d --- /dev/null +++ b/docs/personal-rules-mvp-requirements.md @@ -0,0 +1,161 @@ +# 个人规则功能需求文档(方案 A:本地 `.md` 注入) + +## 1. 文档目标 + +在不改动现有核心对话模式的前提下,实现“个人规则(Personal Rules)”能力: +用户可在插件内维护个人规则文本,插件保存到本地 `.md` 文件;每次发起对话时自动读取并随请求传递给后端,由后端注入模型上下文,影响回答风格与行为。 + +## 2. 范围定义 + +### 2.1 本期范围(MVP) + +1. 支持用户编辑、保存、启用/停用个人规则。 +2. 本地落盘为 `.md` 文件。 +3. 发消息时自动加载规则并传给后端。 +4. 后端接收结构化字段并注入提示词。 +5. 基础异常处理和可观测提示。 + +### 2.2 非本期范围 + +1. 云端同步、多设备同步。 +2. 规则版本历史/回滚。 +3. 多规则集合管理(仅单份个人规则文本)。 +4. 团队共享规则。 + +## 3. 术语与核心概念 + +1. `Personal Rules`:用户个人偏好与约束文本。 +2. `Rules File`:本地规则文件,Markdown 格式。 +3. `Rules Enabled`:规则开关;关闭时不注入。 +4. `Rules Injection`:请求时将规则传后端并参与模型上下文构建。 + +## 4. 用户故事 + +1. 作为用户,我希望在插件里写下“回答风格、代码习惯、语言偏好”等个人规则。 +2. 作为用户,我希望规则保存在本地可见文件中。 +3. 作为用户,我希望发消息时自动生效,无需每次重复输入。 +4. 作为用户,我希望可以一键关闭规则,临时不生效。 + +## 5. 功能需求(前端/Webview + 扩展端) + +### 5.1 规则管理界面 + +1. 提供“个人规则”入口。 +2. 提供多行编辑框(显示当前规则内容)。 +3. 提供“保存”按钮。 +4. 提供“启用/停用”开关。 +5. 显示当前状态: +6. 规则是否启用。 +7. 规则字数/长度。 +8. 最近保存时间(可选)。 + +### 5.2 本地文件存储 + +1. 规则内容保存到本地 `.md`。 +2. 推荐文件名:`personal-rules.md`。 +3. 推荐路径(优先):插件全局存储目录下固定子路径。 +4. 文件不存在时可自动创建。 +5. 用户可通过“打开规则文件”查看(可选)。 + +### 5.3 对话发送前处理 + +1. 用户点击发送消息。 +2. 扩展端检查规则开关: +3. 关闭:不读取规则,不传后端。 +4. 开启:读取 `.md` 内容。 +5. 读取成功且非空时,将规则文本附加到对话请求结构化字段。 +6. 读取失败时:提示告警,但不阻断正常对话。 + +### 5.4 限制与防护 + +1. 规则长度上限(例如 4000 字符,可配置)。 +2. 超限时保存被拒绝,提示用户缩短。 +3. 空白内容视为“无规则”。 +4. 不允许二进制或非文本写入。 + +## 6. 功能需求(后端) + +### 6.1 请求协议扩展 + +在现有对话请求结构中增加字段: + +1. `personalRules`:字符串,可选。 +2. `rulesEnabled`:布尔,可选(便于追踪)。 +3. `rulesMeta`:可选元信息(长度、来源)。 + +### 6.2 注入策略 + +1. 后端收到 `personalRules` 后,将其注入系统提示层(而非用户消息层)。 +2. 注入顺序建议: +3. 系统安全与平台策略。 +4. 产品默认系统提示。 +5. 用户个人规则。 +6. 用户输入。 +7. 若 `personalRules` 为空或开关关闭,则跳过注入。 + +### 6.3 风险控制 + +1. 规则文本不允许覆盖平台安全策略。 +2. 记录本次是否注入规则(日志字段即可)。 +3. 异常不应导致整次对话失败(可降级为无规则对话)。 + +## 7. 前后端对接设计 + +### 7.1 消息链路 + +1. Webview 触发 `sendMessage`。 +2. 扩展端 `messageHandler` 统一处理发送。 +3. `messageHandler` 在调用 `dialogService.sendMessage` 前读取个人规则。 +4. `dialogService` 组装 `DialogRequest`,带上 `personalRules`。 +5. `sseHandler` 发起流式请求。 +6. 后端注入规则后进入模型推理。 +7. 正常走现有 SSE 回传流程。 + +### 7.2 职责边界 + +1. Webview:展示与编辑,不直接拼接最终请求。 +2. 扩展端:规则文件读写、开关状态管理、请求组装。 +3. 后端:规则注入、优先级控制、审计日志。 + +## 8. 数据与状态设计 + +### 8.1 本地文件 + +1. 文件格式:Markdown 纯文本。 +2. 内容约定:无强制模板,允许自由文本。 +3. 编码:UTF-8。 + +### 8.2 本地配置状态 + +1. `personalRulesEnabled`:是否启用。 +2. `personalRulesPath`:规则文件路径(可固定也可配置)。 +3. `lastSavedAt`:最近保存时间(可选)。 + +## 9. 异常与降级 + +1. 文件不存在:自动创建空文件,视为无规则。 +2. 文件读取失败:弹出提示,继续无规则发送。 +3. 文件写入失败:保存失败提示,不更新状态。 +4. 后端字段不识别:请求兼容,后端忽略新字段。 +5. 后端注入失败:降级为普通对话,记录日志。 + +## 10. 安全与合规要求 + +1. 个人规则属于用户本地数据,不主动上传除非发起对话。 +2. 日志中避免完整打印规则正文(最多打印长度和哈希)。 +3. 后端注入时必须确保平台安全策略优先级更高。 + +## 11. 验收标准(UAT) + +1. 用户保存规则后,本地存在 `personal-rules.md` 且内容一致。 +2. 开启规则发送消息时,请求中可观测到 `personalRules` 字段。 +3. 关闭规则发送消息时,请求中不含该字段或为空。 +4. 规则文件损坏/读取失败时,不影响正常聊天。 +5. 超过长度上限时,前端保存被拒绝且提示明确。 +6. 后端日志可确认“本次是否注入个人规则”。 + +## 12. 迭代建议(下一阶段) + +1. 规则模板(代码风格、语言风格、测试偏好)。 +2. 项目规则与个人规则合并策略。 +3. 云端同步(按 `userId`),多端一致。 diff --git a/src/panels/ICHelperPanel.ts b/src/panels/ICHelperPanel.ts index ff5c5ff..76899c0 100644 --- a/src/panels/ICHelperPanel.ts +++ b/src/panels/ICHelperPanel.ts @@ -758,6 +758,23 @@ export async function showICHelperPanel( } } break; + // 打开文件 + case "openFile": + { + let filePath = message.filePath; + if (filePath) { + // 如果是相对路径,转换为绝对路径 + if (!require("path").isAbsolute(filePath)) { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (workspaceFolder) { + filePath = require("path").join(workspaceFolder.uri.fsPath, filePath); + } + } + const uri = vscode.Uri.file(filePath); + vscode.window.showTextDocument(uri); + } + } + break; // 新增:检查工作区状态 case "checkWorkspace": const hasWorkspace = !!( diff --git a/src/views/filePathTag.ts b/src/views/filePathTag.ts new file mode 100644 index 0000000..72b14e5 --- /dev/null +++ b/src/views/filePathTag.ts @@ -0,0 +1,62 @@ +/** + * 文件路径标签组件 + * 功能:显示可点击的文件路径标签 + * 使用场景:在用户消息中显示上下文文件 + */ + +/** + * 获取文件路径标签的样式 + */ +export function getFilePathTagStyles(): string { + return ` + /* 文件路径标签 */ + .file-path-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + margin-right: 6px; + background: rgba(0, 122, 204, 0.15); + border: 1px solid rgba(0, 122, 204, 0.3); + border-radius: 4px; + color: #4fc3f7; + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; + font-family: 'Consolas', 'Monaco', monospace; + } + + .file-path-tag:hover { + background: rgba(0, 122, 204, 0.25); + border-color: rgba(0, 122, 204, 0.5); + } + + .file-path-tag svg { + width: 12px; + height: 12px; + opacity: 0.8; + } + `; +} + +/** + * 获取文件路径标签的脚本 + */ +export function getFilePathTagScript(): string { + return ` + // 处理文件路径标签点击 + function handleFilePathClick(filePath) { + vscode.postMessage({ + command: 'openFile', + filePath: filePath + }); + } + + // 创建文件路径标签 + window.createFilePathTag = function(filePath) { + const fileIcon = ''; + const escapedPath = filePath.replace(/\\\\/g, '\\\\\\\\').replace(/'/g, "\\\\'"); + return '' + fileIcon + filePath + ''; + }; + `; +} diff --git a/src/views/inputArea.ts b/src/views/inputArea.ts index 9da5dc6..9958e1d 100644 --- a/src/views/inputArea.ts +++ b/src/views/inputArea.ts @@ -24,6 +24,10 @@ import { getContextCompressStyles, getContextCompressScript, } from "./contextCompress"; +import { + getFilePathTagStyles, + getFilePathTagScript, +} from "./filePathTag"; import { getOptimizeButtonContent, getOptimizeButtonStyles, @@ -98,6 +102,7 @@ export function getInputAreaStyles(): string { ${getModelSelectorStyles()} ${getContextButtonStyles()} ${getContextDisplayStyles()} + ${getFilePathTagStyles()} ${getContextCompressStyles()} ${getOptimizeButtonStyles()} ${getExampleShowcaseStyles()} @@ -309,6 +314,7 @@ export function getInputAreaScript(): string { ${getContextCompressScript()} ${getOptimizeButtonScript()} ${getChangePanelScript()} + ${getFilePathTagScript()} // 对话状态管理 let isConversationActive = false; @@ -426,7 +432,19 @@ export function getInputAreaScript(): string { // 获取上下文项 const contextItems = window.getContextItems ? window.getContextItems() : []; - addMessage(text, 'user'); + // 构建显示消息:如果有上下文文件,添加文件路径前缀 + let displayText = text; + if (contextItems.length > 0) { + const filePaths = contextItems + .filter(item => item.type === 'file') + .map(item => item.displayPath || item.path) + .join(' '); + if (filePaths) { + displayText = filePaths + ' ' + text; + } + } + + addMessage(displayText, 'user'); // 标记已有消息,切换布局到底部 hasMessages = true; diff --git a/src/views/messageArea.ts b/src/views/messageArea.ts index 61d1950..e891af7 100644 --- a/src/views/messageArea.ts +++ b/src/views/messageArea.ts @@ -850,7 +850,26 @@ export function getMessageAreaScript(): string { div.appendChild(actionsDiv); } else { - div.textContent = text; + // 用户消息:解析文件路径并转换为标签 + const parts = text.split(' '); + const filePaths = []; + const textParts = []; + + parts.forEach(part => { + // 判断是否为文件路径:包含路径分隔符或文件扩展名 + if (part.includes('/') || part.includes('\\\\') || /\\.[a-zA-Z0-9]+$/.test(part)) { + filePaths.push(part); + } else { + textParts.push(part); + } + }); + + if (filePaths.length > 0) { + div.innerHTML = filePaths.map(fp => window.createFilePathTag ? window.createFilePathTag(fp) : fp).join('') + ' ' + textParts.join(' '); + } else { + div.textContent = text; + } + // 当添加用户消息时,隐藏 header hideHeaderIfNeeded(); }