From 7cddfaba30869d661878b55bdb08f4ba6834d871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cydy0615=E2=80=9D?= <“allenyuan410@gmail.com”> Date: Fri, 13 Feb 2026 21:17:45 +0800 Subject: [PATCH] feat(editor): add Markdown rendering for ghost text and optimize prompt system - Implement Markdown parsing for ghost text using Milkdown parser - Add support for both block and inline content in ghost text - Refactor prompt system with comprehensive rules and examples - Adjust LLM parameters: increase temperature to 0.7, add repeat_penalty - Add CSS styles for formatted ghost text (bold, italic, code, links) - Add documentation for Copilot prompt system and ghost text rendering --- backend/__pycache__/prompt.cpython-313.pyc | Bin 1493 -> 9003 bytes backend/llm.py | 8 +- backend/main.py | 1 - backend/prompt.py | 214 +++++++++- plans/copilot-prompt-system-analysis.md | 473 +++++++++++++++++++++ plans/ghost-text-markdown-rendering.md | 157 +++++++ src/components/MilkdownEditor.vue | 19 +- src/plugins/copilotPlugin.ts | 124 +++++- 8 files changed, 966 insertions(+), 30 deletions(-) create mode 100644 plans/copilot-prompt-system-analysis.md create mode 100644 plans/ghost-text-markdown-rendering.md diff --git a/backend/__pycache__/prompt.cpython-313.pyc b/backend/__pycache__/prompt.cpython-313.pyc index f7020a7b9da20f8cf3470d98875ecee5a5da881c..4b8e849448cc06eae52f1c10345c28a3732d1dbb 100644 GIT binary patch literal 9003 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 W?DD<45qkYiUA?|v|EIi`r2Yfy-vVs_ literal 1493 zcmaJ>K};K05dCYf7qbFW2hsp(&>ldFOR%N3=>btv2T{olHkH?_5-Aeem~Yr*`D^{% z6}albs?tD}5L$$sw17M7j0siXPBbt)e8h@y!xy&z*0ZmZGX7{XaXi z@4uNpv$K1xts(*$_-RSo;1PPojC~Or4|_p)SVuCFxpRmz?K;PO?4m9S$?gkonTO@M z;C@e#g>H0-r~EV*XsWQou>j|I5_Ve0j*zA}rOi8nX{TzMV#}r^>1qUF$^**{YtPHo z0&>Kqa3|Rt5Y>l}!pSZMBJK(I0MpC^3Gj$;3W~$2!y0RNG8s{jabybxrP76DIS@Zg#)-Ixk(XxNcJjCus6T!AzsBBXIDBTryF)J3W&A= zhFDsAy!(8kxN^Px?UmBCP*9)(=?v%c}TYS1+eDJiCzg@g}t-Q8bT3o68klTHfuPiJBti1JO@#;6lt6vuH zKW0Rg9*9XcV!s;*~(qBt@hON>q=2E~_~`4J^9kH=qb-jilkdfAlbW-yRjhLmhj z1jPaAEKV8VJqRC!=n##?W&8jNrz}hlVN!;!8*>cA=vW>nwG^bD35a4`85@-c;b2s? zk~9C%nN~)}ryvCayGo{jpU!H;(2eO#4XqJN)rbVPmQ-~q1rfmiDO3SV2GKLMOE=P2 zEzKaOBw|=IEDNlg7_gD(C*$!%IF=ZcWJ`q-9z;u18pTNqcv94u{nv%0v|&Q64I+y5 z0@^LmmV;&atgT+&NK6SQBIB{afZOqyX3e}GW(;27bqOIvu5Q1^hW5C5yH@}B1 zi>e~(I$q$9{wW}@Z|CUoTzK{TqPXKf_OritQ2?Fea;}x`Md3wz*NvgopyQv zz9U@qE#zv;wk>|1i{{Q$k*nWVJ@JOmpZ%!Xi&~B>4}CRsZMeX9?LnF-;Bj1r=?GRP z4POCeKNZIfji}w`9-^J#pl>p-cWBm_vUNO6Pk_m$&+LP(>gG7^SJeC)>a6m8Tz9U$ Ria^~;)>J AsyncGenerator[str, None]: messages=[{'role': 'user', 'content': prompt}], stream=True, options={ - 'num_predict': 8192, - 'temperature': 0.2, - } + 'temperature': 0.7, + 'repeat_penalty': 1.1, + }, + think='high' ) print(f"[LLM] Got stream object, starting iteration...") diff --git a/backend/main.py b/backend/main.py index 2fad675..77dd5a7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -89,7 +89,6 @@ async def create_completion(request: CompletionRequest): messages=[{'role': 'user', 'content': prompt}], stream=False, options={ - 'num_predict': 8192, 'temperature': 0.2, } ) diff --git a/backend/prompt.py b/backend/prompt.py index e19d2da..bcf02c7 100644 --- a/backend/prompt.py +++ b/backend/prompt.py @@ -3,39 +3,209 @@ from typing import Tuple def build_prompt(prefix: str, suffix: str) -> str: """ - 改进后的提示词构建函数。 - 使用更明确的指令来引导模型生成高质量的续写内容。 + 优化后的提示词构建函数。 + 使用明确的分隔符区分指令部分和实际的 prefix/suffix 内容。 """ - MAX_CONTEXT_LINES = 30 - # 修正:把suffix的第一个字符移到prefix末尾(解决光标位置偏差) if suffix: first_char = suffix[0] prefix = prefix + first_char suffix = suffix[1:] - prefix_lines = prefix.split('\n') - suffix_lines = suffix.split('\n') if suffix else [] - - recent_prefix = '\n'.join(prefix_lines[-MAX_CONTEXT_LINES:]) - recent_suffix = '\n'.join(suffix_lines[:5]) + recent_prefix = prefix + recent_suffix = suffix - prompt = f"""You are an expert writing assistant. Continue the text naturally. + prompt = f"""You are an expert writing assistant integrated into a text editor. Your task is to complete the text at the cursor position. -CONTEXT: -⟨CURSOR⟩ marks where to continue. -- Before ⟨CURSOR⟩: existing text -- After ⟨CURSOR⟩: following context (if any) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +RULES +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -RULES: -- Match existing style, tone, and terminology -- Maintain logical flow -- Write only the continuation, nothing else -- IMPORTANT: Start continuation directly from ⟨CURSOR⟩ position +RULE #1: SEAMLESS CONNECTION (MOST CRITICAL) -TEXT: -{recent_prefix}⟨CURSOR⟩{recent_suffix} +Your continuation MUST seamlessly bridge the prefix and suffix. This is the MOST IMPORTANT rule. -CONTINUATION:""" +The "复读机" (Parrot) Error is when you repeat content that already exists in the suffix. This is the WORST mistake you can make. + +Requirements: +- Your output must connect prefix to suffix smoothly +- NEVER repeat content that already exists in the suffix +- If prefix already flows naturally into suffix, output NOTHING (empty string) +- The result should read as one coherent text, as if you never interrupted it + +RULE #2: WHITESPACE & PUNCTUATION + +You must carefully check the LAST character of prefix and FIRST character of suffix to ensure perfect docking. + +Requirements: +- If prefix ends with space, do NOT start your output with space (prevents double spaces) +- If prefix does NOT end with space and suffix starts with a letter, you may need to add a space +- If suffix starts with punctuation, do NOT end your output with the same punctuation +- Check for existing spaces around operators before adding more + +RULE #3: INDENTATION ALIGNMENT + +You MUST match the indentation level of the current context. + +Requirements: +- Look at the line where cursor is positioned +- Count the leading spaces/tabs on that line +- Match that indentation for new lines +- Use the SAME type of indentation (spaces OR tabs) as the existing code +- For nested blocks, increase indentation appropriately +- For closing braces, match the opening brace's indentation + +RULE #4: LIST MAINTENANCE + +When the prefix ends with a list marker, you MUST recognize the pattern and continue it appropriately. + +Requirements: +- "- [ ] " indicates an unchecked task → continue with task description +- "- [x] " indicates a checked task → continue with completed task description +- "1. ", "2. ", etc. indicates ordered list → increment the number +- "* " or "- " indicates bullet list → continue with same marker style +- "> " indicates blockquote → continue quoted text +- Maintain the same list format and indentation level + +RULE #5: SYNTAX CLOSURE + +Before generating content, CHECK if there are unclosed syntax elements. If so, you MUST close them FIRST. + +Requirements: +- Scan prefix for opening markers: **, *, `, [, ![, ``` +- Check if each opening has a corresponding closing +- If unclosed, add the closing marker FIRST before continuing with content +- Markdown syntax pairs to check: + - Bold: ** must have closing ** + - Italic: * must have closing * + - Bold + Italic: *** must have closing *** + - Inline code: ` must have closing ` + - Code block: ``` must have closing ``` + - Link: [text must have closing ](url) + - Image: ![alt must have closing ](url) + +RULE #6: OUTPUT FORMAT + +Your output will be directly inserted into the document. Output ONLY the continuation text. + +Requirements: +- Output ONLY the text that should appear at the cursor position +- NO explanations, NO comments, NO meta-text +- NO code blocks wrapping your output +- NO phrases like "Here's the continuation:" or "I'll complete this for you:" +- Your output is inserted DIRECTLY into the user's document + +RULE #7: ALWAYS OUTPUT SOMETHING (MANDATORY) + +You MUST always output some content. Empty output is NOT allowed. + +Requirements: +- Even if the prefix seems complete, you should suggest a natural continuation +- If the prefix ends mid-sentence, complete the sentence +- If the prefix ends at a natural break point, suggest the next logical content +- Examples of valid continuations: + - Add the next word or phrase + - Complete an incomplete thought + - Add a relevant follow-up sentence + - Continue a list with the next item + - Add closing punctuation if missing +- NEVER output an empty string - always provide some useful continuation + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +EXAMPLES +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +EXAMPLE 1 - Seamless Connection: +The quick brown fox +jumps over the lazy dog. +Output: "" (empty - nothing needed, prefix already connects to suffix) +Result: "The quick brown fox jumps over the lazy dog." + +EXAMPLE 2 - Seamless Connection with Space: +Hello +world! +Output: " " +Result: "Hello world!" + +EXAMPLE 3 - Whitespace Docking: +const a = +1; +Output: "1;" +Result: "const a = 1;" + +EXAMPLE 4 - Indentation Alignment: +function test() {{\\n if (true) {{\\n console.log('hi');\\n +\\n}} +Output: "}}\\n}}" +Result: " }}\\n}}" (correctly closes if with 4 spaces, then function) + +EXAMPLE 5 - Task List: +## TODO\\n- [ ] Buy groceries\\n- [ ] + +Output: "Call mom" +Result: "## TODO\\n- [ ] Buy groceries\\n- [ ] Call mom" + +EXAMPLE 6 - Ordered List: +1. First item\\n2. Second item\\n + +Output: "3. Third item" +Result: "1. First item\\n2. Second item\\n3. Third item" + +EXAMPLE 7 - Bullet List: +* Apple\\n* Banana\\n* + +Output: "Cherry" +Result: "* Apple\\n* Banana\\n* Cherry" + +EXAMPLE 8 - Unclosed Bold: +This is **important + text continues here. +Output: "** " +Result: "This is **important** text continues here." + +EXAMPLE 9 - Unclosed Link: +Click [here for more + information. +Output: "](https://example.com)" +Result: "Click [here for more](https://example.com) information." + +EXAMPLE 10 - Unclosed Code Block: +```python\\ndef hello(): +\\nprint('done') +Output: "\\n print('hello')\\n```" +Result: Code block properly closed with ``` + +EXAMPLE 11 - Clean Output: +For any completion, output ONLY the continuation text: +Output: "Hello world!" +NOT: "Here's what comes next: Hello world!" +NOT: "```Hello world```" +NOT: "I'll complete this for you: Hello world!" + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +FINAL CHECKLIST +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Before outputting, verify: +□ Does my output connect prefix and suffix WITHOUT repeating suffix content? +□ Are there no double spaces or missing spaces between prefix and suffix? +□ Does my indentation match the context? +□ If there's a list marker, did I continue the list pattern? +□ Did I close any unclosed Markdown syntax? +□ Is my output ONLY the continuation text, nothing else? + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +NOW COMPLETE THE FOLLOWING TEXT +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + +{recent_prefix} + + + +{recent_suffix} + + +Output:""" return prompt.strip() diff --git a/plans/copilot-prompt-system-analysis.md b/plans/copilot-prompt-system-analysis.md new file mode 100644 index 0000000..b400a12 --- /dev/null +++ b/plans/copilot-prompt-system-analysis.md @@ -0,0 +1,473 @@ +# GitHub Copilot 提示词系统分析 + +## 概述 + +GitHub Copilot 的提示词系统是一个复杂的代码补全引擎,采用声明式组件架构来构建发送给 LLM 的提示词。本文档基于 `completions-sample-code/` 目录的源代码分析。 + +## 核心架构 + +```mermaid +flowchart TB + subgraph Input + A[用户光标位置] --> B[CompletionState] + C[当前文档] --> B + D[相似文件] --> B + end + + subgraph PromptFactory + B --> E[VirtualPrompt] + E --> F[组件树构建] + end + + subgraph Components + F --> G[CompletionsContext] + G --> H[DocumentMarker] + G --> I[Traits] + G --> J[Diagnostics] + G --> K[CodeSnippets] + G --> L[SimilarFiles] + G --> M[RecentEdits] + F --> N[CurrentFile] + end + + subgraph Rendering + H --> O[CompletionsPromptRenderer] + I --> O + J --> O + K --> O + L --> O + M --> O + N --> O + O --> P[Prompt对象] + end + + subgraph Output + P --> Q[API请求] + Q --> R[LLM补全] + end +``` + +## 1. 提示词基础配置 + +### 1.1 Token 限制 + +来源: [`prompt/src/prompt.ts`](../completions-sample-code/prompt/src/prompt.ts) + +```typescript +// 最大补全长度 +export const DEFAULT_MAX_COMPLETION_LENGTH = 500; + +// 最大提示词长度 (模型上下文窗口 - 补全长度) +export const DEFAULT_MAX_PROMPT_LENGTH = 8192 - DEFAULT_MAX_COMPLETION_LENGTH; + +// 默认代码片段数量 +export const DEFAULT_NUM_SNIPPETS = 4; + +// 后缀匹配阈值 +export const DEFAULT_SUFFIX_MATCH_THRESHOLD = 10; +``` + +### 1.2 提示词分配比例 + +```typescript +export const DEFAULT_PROMPT_ALLOCATION_PERCENT = { + prefix: 35, // 光标前代码 + suffix: 15, // 光标后代码 + stableContext: 35, // 稳定上下文 + volatileContext: 15 // 动态上下文 +}; +``` + +## 2. 语言标记系统 + +### 2.1 支持的语言 + +来源: [`prompt/src/languageMarker.ts`](../completions-sample-code/prompt/src/languageMarker.ts) + +支持 60+ 种编程语言,每种语言定义了: +- `lineComment`: 单行注释标记 (start, end) +- `markdownLanguageIds`: Markdown 代码块语言标识符 + +示例: +```typescript +python: { + lineComment: { start: '#', end: '' }, + markdownLanguageIds: ['python', 'py', 'gyp'], +}, +javascript: { + lineComment: { start: '//', end: '' }, + markdownLanguageIds: ['javascript', 'js'], +}, +``` + +### 2.2 语言标记生成 + +```typescript +// 获取语言标记 +export function getLanguageMarker(doc: DocumentInfo): string { + if (dontAddLanguageMarker.indexOf(languageId) === -1 && !hasLanguageMarker(doc)) { + if (languageId in shebangLines) { + return shebangLines[languageId]; // 如 #!/usr/bin/env python3 + } else { + return `Language: ${languageId}`; + } + } + return ''; +} + +// 获取路径标记 +export function getPathMarker(doc: DocumentInfo): string { + if (doc.relativePath) { + return `Path: ${doc.relativePath}`; + } + return ''; +} +``` + +## 3. 组件系统架构 + +### 3.1 声明式组件 + +来源: [`prompt/src/components/components.ts`](../completions-sample-code/prompt/src/components/components.ts) + +Copilot 使用类似 React 的 JSX 语法来声明提示词组件: + +```typescript +// 基础组件类型 +export type PromptElementProps

= P & Readonly; + +// 组件上下文,提供状态管理 +export interface ComponentContext { + useState(initialState: S): [S, Dispatch>]; + useData(typePredicate: TypePredicate, consumer: DataConsumer): void; +} +``` + +### 3.2 默认提示词组件结构 + +来源: [`lib/src/prompt/completionsPromptFactory/componentsCompletionsPromptFactory.tsx`](../completions-sample-code/lib/src/prompt/completionsPromptFactory/componentsCompletionsPromptFactory.tsx) + +```tsx +function defaultCompletionsPrompt(accessor: ServicesAccessor) { + return ( + <> + + + + + + + + + + + ); +} +``` + +### 3.3 组件权重说明 + +| 组件 | 权重 | 说明 | +|------|------|------| +| RecentEdits | 0.99 | 最近编辑内容,最高优先级 | +| CodeSnippets | 0.9 | 代码片段 | +| SimilarFiles | 0.8 | 相似文件内容 | +| DocumentMarker | 0.7 | 文档标记(语言/路径) | +| Diagnostics | 0.65 | 诊断信息(错误/警告) | +| Traits | 0.6 | 代码特征 | +| CurrentFile | 1.0 | 当前文件内容(必须包含) | + +## 4. 当前文件组件 + +来源: [`lib/src/prompt/components/currentFile.tsx`](../completions-sample-code/lib/src/prompt/components/currentFile.tsx) + +### 4.1 光标前代码 (BeforeCursor) + +```tsx +export function BeforeCursor(props: { + document: CompletionRequestDocument | undefined; + position: Position | undefined; + maxCharacters: number; +}) { + let text = props.document.getText({ start: { line: 0, character: 0 }, end: props.position }); + if (text.length > props.maxCharacters) { + text = text.slice(-props.maxCharacters); // 截取最后 maxCharacters 字符 + } + return {text}; +} +``` + +### 4.2 光标后代码 (AfterCursor) + +```tsx +export function AfterCursor(props: {...}, context: ComponentContext) { + // 获取光标后所有文本 + let suffix = props.document.getText({ + start: props.position, + end: { line: Number.MAX_VALUE, character: Number.MAX_VALUE }, + }); + + // 后缀缓存机制:使用编辑距离判断是否复用缓存 + const dist = findEditDistanceScore(firstSuffixTokens.tokens, cachedSuffixTokens.tokens); + if (100 * dist < suffixMatchThreshold * tokens.length) { + suffixToUse = cachedSuffix; // 使用缓存的后缀 + } + + return {suffixToUse}; +} +``` + +## 5. 相似文件与代码片段 + +### 5.1 相似文件选择 + +来源: [`prompt/src/snippetInclusion/similarFiles.ts`](../completions-sample-code/prompt/src/snippetInclusion/similarFiles.ts) + +```typescript +export interface SimilarFilesOptions { + snippetLength: number; // 代码片段长度(行数) + threshold: number; // 相似度阈值 + maxTopSnippets: number; // 最大返回片段数 + maxCharPerFile: number; // 每文件最大字符数 + maxNumberOfFiles: number; // 最大文件数 + maxSnippetsPerFile: number; // 每文件最大片段数 +} + +// 默认配置 +export const defaultSimilarFilesOptions: SimilarFilesOptions = { + snippetLength: 60, + threshold: 0.0, + maxTopSnippets: 4, + maxCharPerFile: 10000, + maxNumberOfFiles: 20, + maxSnippetsPerFile: 1, +}; +``` + +### 5.2 Jaccard 相似度匹配 + +来源: [`prompt/src/snippetInclusion/selectRelevance.ts`](../completions-sample-code/prompt/src/snippetInclusion/selectRelevance.ts) + +```typescript +// 使用 Jaccard 相似度计算代码片段相关性 +abstract class WindowedMatcher { + protected abstract similarityScore(a: Set, b: Set): number; + + // 分词器:将代码转换为 token 集合 + class Tokenizer { + tokenize(a: string): Set { + return new Set(splitIntoWords(a).filter(x => !this.stopsForLanguage.has(x))); + } + } +} +``` + +### 5.3 代码片段格式化 + +来源: [`prompt/src/snippetInclusion/snippets.ts`](../completions-sample-code/prompt/src/snippetInclusion/snippets.ts) + +```typescript +export function announceSnippet(snippet: SnippetToAnnounce) { + const headline = snippet.relativePath + ? `Compare ${pluralizedSemantics} ${semantics} from ${snippet.relativePath}:` + : `Compare ${pluralizedSemantics} ${semantics}:`; + return { headline, snippet: snippet.snippet }; +} +``` + +## 6. API 请求格式 + +### 6.1 请求结构 + +来源: [`lib/src/openai/fetch.ts`](../completions-sample-code/lib/src/openai/fetch.ts) + +```typescript +type CompletionRequest = { + prompt: string; // 前缀代码 + suffix: string; // 后缀代码 + stream: true; // 始终使用流式响应 + max_tokens: number; // 最大生成 token 数 + n: number; // 并行补全数量 + temperature: number; // 温度参数 + top_p: number; // nucleus 采样参数 + stop: string[]; // 停止标记 + logprobs?: number; // logprob 数量 + extra: { + language: string; // 语言 ID + trim_by_indentation?: boolean; + force_indent?: number; + next_indent?: number; + prompt_tokens: number; + suffix_tokens: number; + context?: string[]; // 额外上下文 + }; +}; +``` + +### 6.2 停止标记 + +来源: [`lib/src/openai/openai.ts`](../completions-sample-code/lib/src/openai/openai.ts) + +```typescript +const stopsForLanguage: { [key: string]: string[] } = { + markdown: ['\n\n\n'], + python: ['\ndef ', '\nclass ', '\nif ', '\n\n#'], +}; + +export function getStops(languageId?: string) { + return stopsForLanguage[languageId ?? ''] ?? ['\n\n\n', '\n```']; +} +``` + +### 6.3 温度参数 + +```typescript +export function getTemperatureForSamples(numShots: number): number { + if (numShots <= 1) return 0.0; + else if (numShots < 10) return 0.2; + else if (numShots < 20) return 0.4; + else return 0.8; +} +``` + +## 7. Tokenization + +来源: [`prompt/src/tokenization/tokenizer.ts`](../completions-sample-code/prompt/src/tokenization/tokenizer.ts) + +### 7.1 支持的 Tokenizer + +```typescript +export enum TokenizerName { + cl100k = 'cl100k_base', // GPT-3.5/GPT-4 + o200k = 'o200k_base', // GPT-4o + mock = 'mock', // 测试用 +} +``` + +### 7.2 Tokenizer 接口 + +```typescript +export interface Tokenizer { + tokenLength(text: string): number; + tokenize(text: string): number[]; + detokenize(tokens: number[]): string; + tokenizeStrings(text: string): string[]; + takeLastTokens(text: string, n: number): { text: string; tokens: number[] }; + takeFirstTokens(text: string, n: number): { text: string; tokens: number[] }; + takeLastLinesTokens(text: string, n: number): string; +} +``` + +## 8. Tree-sitter 代码解析 + +来源: [`prompt/src/parse.ts`](../completions-sample-code/prompt/src/parse.ts) + +### 8.1 支持的语言 + +```typescript +export enum WASMLanguage { + Python = 'python', + JavaScript = 'javascript', + TypeScript = 'typescript', + TSX = 'tsx', + Go = 'go', + Ruby = 'ruby', + CSharp = 'c-sharp', + Java = 'java', + Php = 'php', + Cpp = 'cpp', +} +``` + +### 8.2 用途 + +- 判断代码块是否为空块开始 (`isEmptyBlockStart`) +- 判断代码块是否完成 (`isBlockBodyFinished`) +- 获取语法节点起始位置 (`getNodeStart`) + +## 9. 提示词构建流程 + +```mermaid +sequenceDiagram + participant User as 用户 + participant VSCode as VS Code + participant GT as GhostText + participant PF as PromptFactory + participant Components as 组件系统 + participant Tokenizer as Tokenizer + participant API as OpenAI API + + User->>VSCode: 输入代码 + VSCode->>GT: 请求补全 + GT->>PF: extractPrompt + PF->>Components: 构建组件树 + + Components->>Components: DocumentMarker + Components->>Components: Traits + Components->>Components: Diagnostics + Components->>Components: CodeSnippets + Components->>Components: SimilarFiles + Components->>Components: RecentEdits + Components->>Components: CurrentFile + + Components->>Tokenizer: 计算 token 数量 + Tokenizer-->>Components: 返回 token 数 + + Components->>Components: Elision 省略处理 + Components-->>PF: Prompt 对象 + PF-->>GT: PromptResponse + GT->>API: 发送请求 + API-->>GT: 流式返回补全 + GT-->>VSCode: 显示 Ghost Text + VSCode-->>User: 展示建议 +``` + +## 10. 关键设计模式 + +### 10.1 声明式组件 + +使用 JSX 语法声明提示词结构,支持: +- 组件组合 +- 权重分配 +- 状态管理 +- 数据订阅 + +### 10.2 虚拟提示词树 + +在渲染前构建虚拟树结构,支持: +- 增量更新 +- 高效 diff +- 条件渲染 + +### 10.3 Token 预算管理 + +- 每个组件有权重属性 +- 根据 token 预算动态省略内容 +- 优先保留高权重组件 + +### 10.4 后缀缓存 + +- 使用编辑距离判断后缀相似度 +- 相似时复用缓存的后缀 +- 减少 token 波动,提高缓存命中率 + +## 11. 实现参考 + +如果要在自己的项目中实现类似的提示词系统,需要关注以下核心模块: + +1. **Tokenizer**: 使用 tiktoken 进行准确的 token 计数 +2. **语言标记**: 为不同语言生成适当的标记 +3. **上下文收集**: 收集相似文件、最近编辑等上下文 +4. **Token 预算**: 动态分配 token 给不同组件 +5. **FIM 格式**: 使用 Fill-In-the-Middle 格式发送请求 + +## 总结 + +GitHub Copilot 的提示词系统是一个精心设计的工程系统,核心特点包括: + +1. **模块化组件架构**: 使用声明式组件构建提示词 +2. **智能上下文选择**: 通过 Jaccard 相似度选择相关代码片段 +3. **Token 预算管理**: 动态分配 token 给不同优先级的内容 +4. **多语言支持**: 支持 60+ 种编程语言 +5. **Tree-sitter 解析**: 精确理解代码结构 +6. **流式响应**: 实时返回补全结果 diff --git a/plans/ghost-text-markdown-rendering.md b/plans/ghost-text-markdown-rendering.md new file mode 100644 index 0000000..9a2a686 --- /dev/null +++ b/plans/ghost-text-markdown-rendering.md @@ -0,0 +1,157 @@ +# 虚拟文本 Markdown 渲染解决方案 + +## 问题分析 + +当前虚拟文本(灰色字)无法正确渲染 Markdown 和换行符,根本原因是: + +1. **纯文本插入**:`insertGhostText` 使用 `tr.insertText()` 直接插入纯文本 +2. **绕过解析器**:文本未经过 Milkdown 的 Markdown 解析流程 +3. **节点结构错误**:`\n` 字符被当作普通字符,而非创建新段落节点 + +## 解决方案架构 + +```mermaid +flowchart TB + subgraph 当前流程 + A1[LLM 返回 Markdown] --> B1[insertText 直接插入] + B1 --> C1[添加 copilot_ghost mark] + C1 --> D1[显示为灰色纯文本] + end + + subgraph 新流程 + A2[LLM 返回 Markdown] --> B2[调用 parserCtx 解析] + B2 --> C2[生成 ProseMirror 节点] + C2 --> D2[为所有节点添加 ghost 属性] + D2 --> E2[插入到文档] + E2 --> F2[显示为格式化灰色文本] + end + + style D1 fill:#f99 + style F2 fill:#9f9 +``` + +## 技术方案 + +### 方案一:使用 Milkdown Parser 解析(推荐) + +**优点**: +- 完整支持 Markdown 语法 +- 与编辑器行为一致 +- 自动处理换行 + +**实现步骤**: + +1. 获取 `parserCtx` 从 Milkdown 上下文 +2. 使用 parser 将 Markdown 解析为 ProseMirror Fragment +3. 遍历所有节点,添加 `copilot_ghost` mark +4. 使用 `tr.replaceWith()` 插入节点 + +### 方案二:使用 Decoration API(备选) + +**优点**: +- 不修改实际文档内容 +- 更轻量级 + +**缺点**: +- 实现复杂 +- 可能与某些功能冲突 + +## 详细实现计划 + +### 步骤 1:修改 copilotPlugin.ts + +需要修改以下部分: + +```typescript +// 新增导入 +import { parserCtx } from '@milkdown/kit/core' + +// 修改 insertGhostText 函数 +async function insertGhostText(view: EditorView, suggestion: string, from: number) { + if (!currentCtx || !suggestion) return + + const schema = view.state.schema + const markType = schema.marks.copilot_ghost + + if (!markType) return + + // 使用 parser 解析 Markdown + const parser = currentCtx.get(parserCtx) + const doc = await parser(suggestion) + + if (!doc) return + + // 为所有文本节点添加 ghost mark + const ghostDoc = doc.descendants((node, pos) => { + if (node.isText) { + // 添加 mark + } + }) + + // 插入节点 + const tr = view.state.tr + tr.replaceWith(from, from, ghostDoc.content) + tr.setMeta(COPILOT_PLUGIN_KEY, { from, to: from + doc.content.size, suggestion }) + view.dispatch(tr) +} +``` + +### 步骤 2:处理换行符 + +换行符处理策略: + +| 换行类型 | 处理方式 | +|---------|---------| +| 单个 `\n` | 创建 `hard_break` 节点 | +| 双个 `\n\n` | 创建新段落节点 | +| 列表项换行 | 创建新列表项节点 | + +### 步骤 3:样式处理 + +需要修改 CSS 以支持格式化的虚拟文本: + +```css +.copilot-ghost-text { + color: #999; + opacity: 0.6; + pointer-events: none; +} + +/* 虚拟文本内的格式化元素 */ +.copilot-ghost-text strong, +.copilot-ghost-text em, +.copilot-ghost-text code { + opacity: inherit; +} +``` + +### 步骤 4:状态管理 + +需要跟踪虚拟节点的范围,以便: +- Tab 键接受时正确移除 mark +- 用户输入时正确清除虚拟内容 +- 导出时正确处理虚拟文本 + +## 文件修改清单 + +| 文件 | 修改内容 | +|------|---------| +| `src/plugins/copilotPlugin.ts` | 重构 insertGhostText,添加解析逻辑 | +| `src/components/MilkdownEditor.vue` | 更新 CSS 样式 | +| `src/plugins/types.ts` | 可能需要更新类型定义 | + +## 风险与注意事项 + +1. **性能考虑**:解析 Markdown 可能有延迟,需要考虑用户体验 +2. **嵌套处理**:复杂的 Markdown 结构(如嵌套列表)需要特殊处理 +3. **撤销/重做**:确保虚拟文本的接受/拒绝正确处理 undo stack +4. **光标位置**:插入多段落内容后光标位置需要正确设置 + +## 验收标准 + +- [ ] Markdown 语法正确渲染(粗体、斜体、代码等) +- [ ] 换行符正确转换为段落 +- [ ] Tab 键接受功能正常 +- [ ] Escape 键拒绝功能正常 +- [ ] 导出时虚拟文本正确处理 +- [ ] 性能无明显下降 diff --git a/src/components/MilkdownEditor.vue b/src/components/MilkdownEditor.vue index c5679d0..de57d34 100644 --- a/src/components/MilkdownEditor.vue +++ b/src/components/MilkdownEditor.vue @@ -315,10 +315,27 @@ onUnmounted(() => { color: #999; opacity: 0.6; pointer-events: none; - white-space: pre-wrap; } .copilot-ghost-text.copilot-loading { opacity: 0.4; } + +.copilot-ghost-text strong, +.copilot-ghost-text em, +.copilot-ghost-text code, +.copilot-ghost-text a { + color: inherit; + opacity: inherit; +} + +.copilot-ghost-text code { + background-color: rgba(0, 0, 0, 0.05); + padding: 0.2em 0.4em; + border-radius: 3px; +} + +.copilot-ghost-text a { + text-decoration: underline; +} diff --git a/src/plugins/copilotPlugin.ts b/src/plugins/copilotPlugin.ts index 3c62e3b..1131992 100644 --- a/src/plugins/copilotPlugin.ts +++ b/src/plugins/copilotPlugin.ts @@ -1,5 +1,7 @@ import { Plugin, PluginKey, Selection } from '@milkdown/prose/state' import { $prose, $ctx, $markSchema } from '@milkdown/kit/utils' +import { parserCtx } from '@milkdown/kit/core' +import { Node as ProseNode, Fragment, Slice } from '@milkdown/prose/model' import type { Ctx } from '@milkdown/kit/core' import type { EditorView } from '@milkdown/prose/view' @@ -51,7 +53,74 @@ function clearGhostText(view: EditorView) { } } -function insertGhostText(view: EditorView, suggestion: string, from: number) { +function isBlockNode(node: ProseNode): boolean { + return node.type.isBlock && node.type.name !== 'paragraph' +} + +function hasBlockNodes(doc: ProseNode): boolean { + let hasBlock = false + doc.forEach((node) => { + if (isBlockNode(node)) { + hasBlock = true + } + }) + return hasBlock +} + +function addGhostMarkToNode(node: ProseNode, ghostMarkType: any): ProseNode { + if (node.isText) { + return node.mark(node.marks.concat(ghostMarkType.create())) + } + if (node.isLeaf) { + return node + } + const newContent: ProseNode[] = [] + node.forEach((child) => { + newContent.push(addGhostMarkToNode(child, ghostMarkType)) + }) + return node.copy(Fragment.from(newContent)) +} + +function extractInlineContent(doc: ProseNode, ghostMarkType: any, schema: any): Fragment { + const nodes: ProseNode[] = [] + let isFirstBlock = true + + doc.forEach((blockNode) => { + if (!isFirstBlock) { + const hardBreak = schema.nodes.hard_break?.create() + if (hardBreak) { + nodes.push(hardBreak) + } else { + nodes.push(schema.text('\n', [ghostMarkType.create()])) + } + } + isFirstBlock = false + + blockNode.forEach((inlineNode) => { + if (inlineNode.isText) { + const combinedMarks = inlineNode.marks.concat(ghostMarkType.create()) + nodes.push(inlineNode.mark(combinedMarks)) + } else if (inlineNode.type.name === 'hard_break') { + nodes.push(inlineNode) + } else if (inlineNode.isLeaf) { + nodes.push(inlineNode) + } else if (inlineNode.content.size > 0) { + inlineNode.forEach((nestedNode) => { + if (nestedNode.isText) { + const combinedMarks = nestedNode.marks.concat(ghostMarkType.create()) + nodes.push(nestedNode.mark(combinedMarks)) + } else if (nestedNode.isLeaf) { + nodes.push(nestedNode) + } + }) + } + }) + }) + + return Fragment.from(nodes) +} + +async function insertGhostText(view: EditorView, suggestion: string, from: number) { if (!currentCtx || !suggestion) return const schema = view.state.schema @@ -62,6 +131,47 @@ function insertGhostText(view: EditorView, suggestion: string, from: number) { return } + try { + const parser = currentCtx.get(parserCtx) + const parsedDoc = await parser(suggestion) + + if (!parsedDoc) { + insertPlainText(view, suggestion, from, markType) + return + } + + const containsBlocks = hasBlockNodes(parsedDoc) + + if (containsBlocks) { + const $from = view.state.doc.resolve(from) + const insertPos = $from.after($from.depth) + + const blockNodes: ProseNode[] = [] + parsedDoc.forEach((node) => { + blockNodes.push(addGhostMarkToNode(node, markType)) + }) + + const fragment = Fragment.from(blockNodes) + const tr = view.state.tr + tr.insert(insertPos, fragment) + const endPos = insertPos + fragment.size + tr.setMeta(COPILOT_PLUGIN_KEY, { from: insertPos, to: endPos, suggestion }) + view.dispatch(tr) + } else { + const inlineFragment = extractInlineContent(parsedDoc, markType, schema) + const tr = view.state.tr + tr.insert(from, inlineFragment) + const endPos = from + inlineFragment.size + tr.setMeta(COPILOT_PLUGIN_KEY, { from, to: endPos, suggestion }) + view.dispatch(tr) + } + } catch (e) { + console.error('[Copilot] Parser error:', e) + insertPlainText(view, suggestion, from, markType) + } +} + +function insertPlainText(view: EditorView, suggestion: string, from: number, markType: any) { const tr = view.state.tr tr.insertText(suggestion, from) const endPos = from + suggestion.length @@ -118,7 +228,17 @@ function acceptSuggestion(view: EditorView) { const state = COPILOT_PLUGIN_KEY.getState(view.state) if (!state?.suggestion || state.from >= state.to) return false - const tr = view.state.tr.removeMark(state.from, state.to, view.state.schema.marks.copilot_ghost) + const tr = view.state.tr + const doc = tr.doc + const from = state.from + const to = state.to + + doc.nodesBetween(from, to, (node, pos) => { + if (node.marks.some((m: any) => m.type.name === 'copilot_ghost')) { + tr.removeMark(pos, pos + node.nodeSize, view.state.schema.marks.copilot_ghost) + } + }) + const endPos = Math.min(state.to, tr.doc.content.size) tr.setSelection(Selection.near(tr.doc.resolve(endPos))) tr.setMeta(COPILOT_PLUGIN_KEY, { ...initialState })