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
This commit is contained in:
“ydy0615”
2026-02-13 21:17:45 +08:00
parent 65d4a57d33
commit 7cddfaba30
8 changed files with 966 additions and 30 deletions
Binary file not shown.
+4 -4
View File
@@ -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...")
-1
View File
@@ -89,7 +89,6 @@ async def create_completion(request: CompletionRequest):
messages=[{'role': 'user', 'content': prompt}],
stream=False,
options={
'num_predict': 8192,
'temperature': 0.2,
}
)
+192 -22
View File
@@ -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:
<PREFIX>The quick brown fox </PREFIX>
<SUFFIX>jumps over the lazy dog.</SUFFIX>
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:
<PREFIX>Hello</PREFIX>
<SUFFIX>world!</SUFFIX>
Output: " "
Result: "Hello world!"
EXAMPLE 3 - Whitespace Docking:
<PREFIX>const a = </PREFIX>
<SUFFIX>1;</SUFFIX>
Output: "1;"
Result: "const a = 1;"
EXAMPLE 4 - Indentation Alignment:
<PREFIX>function test() {{\\n if (true) {{\\n console.log('hi');\\n </PREFIX>
<SUFFIX>\\n}}</SUFFIX>
Output: "}}\\n}}"
Result: " }}\\n}}" (correctly closes if with 4 spaces, then function)
EXAMPLE 5 - Task List:
<PREFIX>## TODO\\n- [ ] Buy groceries\\n- [ ] </PREFIX>
<SUFFIX></SUFFIX>
Output: "Call mom"
Result: "## TODO\\n- [ ] Buy groceries\\n- [ ] Call mom"
EXAMPLE 6 - Ordered List:
<PREFIX>1. First item\\n2. Second item\\n</PREFIX>
<SUFFIX></SUFFIX>
Output: "3. Third item"
Result: "1. First item\\n2. Second item\\n3. Third item"
EXAMPLE 7 - Bullet List:
<PREFIX>* Apple\\n* Banana\\n* </PREFIX>
<SUFFIX></SUFFIX>
Output: "Cherry"
Result: "* Apple\\n* Banana\\n* Cherry"
EXAMPLE 8 - Unclosed Bold:
<PREFIX>This is **important</PREFIX>
<SUFFIX> text continues here.</SUFFIX>
Output: "** "
Result: "This is **important** text continues here."
EXAMPLE 9 - Unclosed Link:
<PREFIX>Click [here for more</PREFIX>
<SUFFIX> information.</SUFFIX>
Output: "](https://example.com)"
Result: "Click [here for more](https://example.com) information."
EXAMPLE 10 - Unclosed Code Block:
<PREFIX>```python\\ndef hello():</PREFIX>
<SUFFIX>\\nprint('done')</SUFFIX>
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
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
<PREFIX>
{recent_prefix}
</PREFIX>
<SUFFIX>
{recent_suffix}
</SUFFIX>
Output:"""
return prompt.strip()
+473
View File
@@ -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 = object> = P & Readonly<PromptAttributes & { children?: PromptComponentChildren }>;
// 组件上下文,提供状态管理
export interface ComponentContext {
useState<S>(initialState: S): [S, Dispatch<StateUpdater<S>>];
useData<T>(typePredicate: TypePredicate<T>, consumer: DataConsumer<T>): void;
}
```
### 3.2 默认提示词组件结构
来源: [`lib/src/prompt/completionsPromptFactory/componentsCompletionsPromptFactory.tsx`](../completions-sample-code/lib/src/prompt/completionsPromptFactory/componentsCompletionsPromptFactory.tsx)
```tsx
function defaultCompletionsPrompt(accessor: ServicesAccessor) {
return (
<>
<CompletionsContext>
<DocumentMarker tdms={tdms} weight={0.7} />
<Traits weight={0.6} />
<Diagnostics tdms={tdms} weight={0.65} />
<CodeSnippets tdms={tdms} weight={0.9} />
<SimilarFiles tdms={tdms} instantiationService={instantiationService} weight={0.8} />
<RecentEdits tdms={tdms} recentEditsProvider={recentEditsProvider} weight={0.99} />
</CompletionsContext>
<CurrentFile weight={1} />
</>
);
}
```
### 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>{text}</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 <Text>{suffixToUse}</Text>;
}
```
## 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<string>, b: Set<string>): number;
// 分词器:将代码转换为 token 集合
class Tokenizer {
tokenize(a: string): Set<string> {
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. **流式响应**: 实时返回补全结果
+157
View File
@@ -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 键拒绝功能正常
- [ ] 导出时虚拟文本正确处理
- [ ] 性能无明显下降
+18 -1
View File
@@ -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;
}
</style>
+122 -2
View File
@@ -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 })