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: **, *, `, [, 
+ - Image: 
+
+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 })