diff --git a/backend/__pycache__/prompt.cpython-313.pyc b/backend/__pycache__/prompt.cpython-313.pyc index f7020a7..4b8e849 100644 Binary files a/backend/__pycache__/prompt.cpython-313.pyc and b/backend/__pycache__/prompt.cpython-313.pyc differ diff --git a/backend/llm.py b/backend/llm.py index 2b6b5d8..2e08db2 100644 --- a/backend/llm.py +++ b/backend/llm.py @@ -6,7 +6,6 @@ from typing import AsyncGenerator OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b') OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://192.168.0.120:11434') -# 移除 /v1/ 后缀(如果有的话),因为 Ollama Python 包使用原生 API if OLLAMA_HOST.endswith('/v1/'): OLLAMA_HOST = OLLAMA_HOST[:-4] elif OLLAMA_HOST.endswith('/v1'): @@ -29,9 +28,10 @@ async def stream_openai(prompt: str) -> 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 })