Prompt IDE:修订间差异
无编辑摘要 |
|||
(未显示同一用户的46个中间版本) | |||
第7行: | 第7行: | ||
于是为了解决这个问题,能够更愉快地测试提示词,笔者撸了一个Vs Code插件——Prompt IDE。目前,尽管缺胳膊少腿,Prompt IDE初具雏形。在这个有趣的过程中,学习到了Vs Code插件开发的一些技巧,也对Vs Code的强大有了更深的认知,在这里简单总结一下。 | 于是为了解决这个问题,能够更愉快地测试提示词,笔者撸了一个Vs Code插件——Prompt IDE。目前,尽管缺胳膊少腿,Prompt IDE初具雏形。在这个有趣的过程中,学习到了Vs Code插件开发的一些技巧,也对Vs Code的强大有了更深的认知,在这里简单总结一下。 | ||
[[Image:Prompt IDE demo.gif|class=fixed-600]] | |||
= 项目起源 = | = 项目起源 = | ||
第31行: | 第33行: | ||
因此,接下来的时间断断续续在思考这件事,最终决定先放弃其他”Native",先把编辑器整清楚,从而产生了这个Prompt IDE。 | 因此,接下来的时间断断续续在思考这件事,最终决定先放弃其他”Native",先把编辑器整清楚,从而产生了这个Prompt IDE。 | ||
= Prompt | = Prompt IDE是如何运行的 = | ||
== 从提示词存储格式开始 == | == 从提示词存储格式开始 == | ||
第77行: | 第79行: | ||
* XML:标记语法,可以通过XML DTD来校验格式,且可以通过<syntaxhighlight lang="xml" inline><![CDATA[...]]></syntaxhighlight>的方式来包裹多行文本,对于多行的提示词十分友好 | * XML:标记语法,可以通过XML DTD来校验格式,且可以通过<syntaxhighlight lang="xml" inline><![CDATA[...]]></syntaxhighlight>的方式来包裹多行文本,对于多行的提示词十分友好 | ||
* YAML:也可以支持多行文本,缺点是没有标记,完全依靠缩进,容易乱 | * YAML:也可以支持多行文本,缺点是没有标记,完全依靠缩进,容易乱 | ||
* | * JSON:可以通过[https://json-schema.org/ JSON Schema]进行校验,缺点是需要对多行、特殊字符进行转义 | ||
最终,经过一些尝试后,最终选择了使用JSON表示。主要原因是,JSON格式校验比较完善,且天然对前端友好,更适合在Vs Code这种基于Web的体系下开发。 | 最终,经过一些尝试后,最终选择了使用JSON表示。主要原因是,JSON格式校验比较完善,且天然对前端友好,更适合在Vs Code这种基于Web的体系下开发。 | ||
第101行: | 第103行: | ||
} | } | ||
</syntaxhighlight> | </syntaxhighlight> | ||
== 对不同的大模型接口进行抽象 == | |||
仅仅使用JSON是不够的,还需要对不同的大模型接口进行一次抽象。 | |||
例如,GPT中的System参数,在接口中是以一个特别的message表示的: | |||
<syntaxhighlight lang="bash"> | |||
"messages": [ | |||
{ | |||
"role": "system", | |||
"content": "Hello" | |||
}, | |||
</syntaxhighlight> | |||
但是其他大模型并没有这个设计。除此之外,一些细微的地方,大家实现起来也各不相同,比如,大模型的回复,GPT叫”assistant",Minimax叫“BOT”。 | |||
所以,必须要对这些参数进行一次抽象,用一个统一的方式来表示提示词。目前的实现,主要参考了GPT和谷歌的AI Studio的做法。例如,通过context定义背景(类似GPT的System、Minimax的人设),通过examples定义Few-shot示例。 | |||
经过抽象后,最终形成一个JSON Schema表示,例如对话模式可以表示如下(部分): | |||
<syntaxhighlight lang="json"> | |||
{ | |||
"context": { | |||
"type": "string" | |||
}, | |||
"examples": { | |||
"type": "array", | |||
"minItems": 1, | |||
"items": { | |||
"$ref": "#/definitions/message" | |||
} | |||
}, | |||
"messages": { | |||
"type": "array", | |||
"minItems": 1, | |||
"items": { | |||
"$ref": "#/definitions/message" | |||
} | |||
} | |||
} | |||
</syntaxhighlight> | |||
== 选取开发框架 == | |||
参照[https://code.visualstudio.com/api/get-started/your-first-extension Vs Code的插件开发文档]可以很容易创建一个插件。但是要做一个复杂的交互页面,还需要进一步对框架进行定制: | |||
* 我们的场景其实是一个提示词编辑器,非常类似于[https://code.visualstudio.com/api/extension-guides/webview WebView文档]中的自定义编辑器的例子。因此,最佳的做法就是通过WebView实现一个网页来嵌入到Vs Code中 | |||
* 纯原生的WebView是手写HTML来操作的,这样生产力太低了。[https://github.com/microsoft/vscode-webview-ui-toolkit vscode-webview-ui-toolkit]是一个Vs Code的控件集,并提供了集中前端框架的[https://github.com/microsoft/vscode-webview-ui-toolkit-samples/tree/main/frameworks 集成示例],例如Angular, React等 | |||
* 使用Typescript而不是Javascript,这样可以更好地进行静态类型检测 | |||
笔者采取的是React+vite的方式集成vscode-webview-ui-toolkit。 最终项目的结构大致如下: | |||
<syntaxhighlight lang="bash"> | |||
- prompt-ide | |||
- package.json | |||
\ src | |||
- extension.ts | |||
\ webview-ui | |||
- package.json | |||
\ src | |||
\ prompt-schema | |||
- package.json | |||
\ src | |||
</syntaxhighlight> | |||
其中,prompt-schema为提示词格式的JSON Schema定义,通过dependency引用。webview-ui为WebView页面的实现,编译后嵌入插件中(index.js, index.css等)。 | |||
这样,其实相当于通过React生成了一个静态的HTML、JS等,然后在Vs Code插件中读取这个HTML,渲染一个Web页面,代码看起来有点不那么优雅: | |||
<syntaxhighlight lang="typescript"> | |||
// 省略了很多细节 | |||
export class PromptEditor implements vscode.CustomTextEditorProvider { | |||
public async resolveCustomTextEditor( | |||
document: vscode.TextDocument, | |||
webviewPanel: vscode.WebviewPanel, | |||
_token: vscode.CancellationToken | |||
): Promise<void> { | |||
webviewPanel.webview.html = this._getWebviewContent(webviewPanel.webview); | |||
} | |||
private _getWebviewContent(webview: Webview) { | |||
const getAsset = (name: string) => | |||
getUri(webview, this.context.extensionUri, ["webview-ui","build","assets",name,]); | |||
const nonce = getNonce(); | |||
return /*html*/ ` | |||
<!DOCTYPE html> | |||
<html lang="en"> | |||
<head> | |||
<meta charset="UTF-8" /> | |||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |||
<link rel="stylesheet" type="text/css" href="${getAsset("index.css")}"> | |||
<title>Prompt Editor</title> | |||
</head> | |||
<body> | |||
<div id="root"></div> | |||
<script type="module" nonce="${nonce}" src="${getAsset("index.js")}"></script> | |||
</body> | |||
</html> | |||
`; | |||
} | |||
} | |||
</syntaxhighlight> | |||
值得一提的是,微软为了避免大家做的插件乱七八糟真是操碎了心,不仅提供了详细的 [https://code.visualstudio.com/api/ux-guidelines/overview UX设计指导],每一个控件什么时候用,什么时候不该用也[https://github.com/microsoft/vscode-webview-ui-toolkit/tree/main/src/button 写的明明白白]。 | |||
== 实现双向编辑同步 == | |||
Prompt IDE的一个核心功能是双向同步。也就是说,在UI界面上的修改会改变文件内容;同时,改变文件内容也会同步到UI。 | |||
如果单纯只是在React中做一个编辑器,其实是十分简单的,我们可以通过一个State来维护当前编辑的提示词: | |||
<syntaxhighlight lang="typescript"> | |||
// webview-ui/src/App.tsx | |||
function App() { | |||
const [prompt, setPrompt] = useState<ChatPrompt | CompletionPrompt | null>(null); | |||
} | |||
</syntaxhighlight> | |||
=== 加载文件内容 === | |||
但是,在Vs Code中打开文件的时候,如何读取文档内容并通过<syntaxhighlight lang="bash" inline>setPrompt</syntaxhighlight>初始化呢? | |||
在上一节的代码中可以看到,如果在插件代码中,我们可以直接通过<syntaxhighlight lang="bash" inline>vscode.TextDocument</syntaxhighlight>对象获取文本文件的内容: | |||
<syntaxhighlight lang="typescript"> | |||
// public async resolveCustomTextEditor( | |||
// document: vscode.TextDocument, ← 看这里 | |||
// webviewPanel: vscode.WebviewPanel, | |||
// _token: vscode.CancellationToken | |||
// ) | |||
document.getText() | |||
</syntaxhighlight> | |||
然而,在webview-ui中无法直接通过这种方式获取到文档内容,只能通过通信的方式来实现,其原理如下: | |||
[[Image:extension-webview-communication.png|600px]]<ref>https://www.eliostruyf.com/simplify-communication-visual-studio-code-extension-webview/</ref> | |||
因此,我们需要通过两步来实现: | |||
首先,在插件启动的时候通过消息发送文件的内容完成初始化: | |||
<syntaxhighlight lang="typescript"> | |||
// src/PromptEditor.ts | |||
import * as vscode from "vscode"; | |||
export class PromptEditor implements vscode.CustomTextEditorProvider { | |||
public async resolveCustomTextEditor( | |||
document: vscode.TextDocument, | |||
webviewPanel: vscode.WebviewPanel, | |||
_token: vscode.CancellationToken | |||
): Promise<void> { | |||
const updateWebview = () => { | |||
webviewPanel.webview.postMessage({ | |||
type: "update", | |||
text: document.getText(), | |||
}); | |||
}; | |||
updateWebview(); | |||
// ... | |||
} | |||
} | |||
</syntaxhighlight> | |||
然后,在WebView中监听消息,加载文件内容 | |||
<syntaxhighlight lang="typescript"> | |||
// webview-ui/src/App.tsx | |||
function App() { | |||
const messageListener = (event: MessageEvent<any>) => { | |||
const message = event.data; | |||
if (message.type == "update") { | |||
try { | |||
const prompt = loadPrompt(message.text); | |||
setPrompt(prompt); | |||
} catch (error) { | |||
// ... | |||
} | |||
} | |||
}; | |||
useEffect(() => { | |||
window.addEventListener("message", messageListener); | |||
return () => { | |||
window.removeEventListener("message", messageListener); | |||
}; | |||
}, []); | |||
} | |||
</syntaxhighlight> | |||
=== 将改动同步到文件 === | |||
在Web编辑器上对提示词的修改等同于修改prompt状态(<syntaxhighlight lang="bash" inline>setPrompt</syntaxhighlight>),这里便不展开。 | |||
现在的问题是,如何把UI上面的修改同步到文件中呢? | |||
从前面的消息模型可以知道,要想把WebView的变更同步到文档,也只能通过发消息的模式。 | |||
首先,在文档变更的时候,发送一个消息: | |||
<syntaxhighlight lang="typescript"> | |||
// webview-ui/src/App.tsx | |||
import { vscode } from "./vscode"; | |||
function App() { | |||
const [prompt, setPrompt] = useState<ChatPrompt | CompletionPrompt | null>(null); | |||
const syncPrompt = (newPrompt: ChatPrompt | CompletionPrompt) => { | |||
vscode.postMessage({ | |||
type: "sync", | |||
text: JSON.stringify(newPrompt, null, 2), | |||
}); | |||
}; | |||
const onPromptChanged = (newPrompt: ChatPrompt | CompletionPrompt) => { | |||
syncPrompt(newPrompt); | |||
setPrompt(newPrompt); | |||
}; | |||
} | |||
</syntaxhighlight> | |||
然后,在插件中监听消息,通过vscode的API完成文档更新: | |||
<syntaxhighlight lang="typescript"> | |||
// src/PromptEditor.ts | |||
export class PromptEditor implements vscode.CustomTextEditorProvider { | |||
public async resolveCustomTextEditor( | |||
document: vscode.TextDocument, | |||
webviewPanel: vscode.WebviewPanel, | |||
_token: vscode.CancellationToken | |||
): Promise<void> { | |||
webviewPanel.webview.onDidReceiveMessage((e) => { | |||
switch (e.type) { | |||
case "sync": | |||
this.updateTextDocument(document, e.text); | |||
break; | |||
default: | |||
break; | |||
} | |||
}); | |||
} | |||
private updateTextDocument(document: vscode.TextDocument, text: string) { | |||
const edit = new vscode.WorkspaceEdit(); | |||
edit.replace( | |||
document.uri, | |||
new vscode.Range(0, 0, document.lineCount, 0), // 做全量更新,如果做的更好可以只更新文档的部分内容 | |||
text | |||
); | |||
return vscode.workspace.applyEdit(edit); | |||
} | |||
} | |||
</syntaxhighlight> | |||
注意到,在App.tsx中我们同样引入了一个“vscode”(<syntaxhighlight lang="typescript" inline>import { vscode } from "./vscode";</syntaxhighlight>,不是说不能直接用么? | |||
其实,这只是个障眼法,通过一个包装类屏蔽了Vscode和浏览器运行的差异: | |||
<syntaxhighlight lang="typescript"> | |||
// webview-ui/src/utilities/vscode.ts | |||
class VSCodeAPIWrapper { | |||
private readonly vsCodeApi: WebviewApi<unknown> | undefined; | |||
constructor() { | |||
if (typeof acquireVsCodeApi === "function") { | |||
this.vsCodeApi = acquireVsCodeApi(); // 只有在vscode中运行时可以获取到,在浏览器直接运行时无法获取 | |||
} | |||
} | |||
public postMessage(message: unknown) { | |||
if (this.vsCodeApi) { | |||
this.vsCodeApi.postMessage(message); | |||
} else { | |||
console.log(message); | |||
} | |||
} | |||
} | |||
</syntaxhighlight> | |||
=== 将改动同步到WebView === | |||
如果用户在Vs Code的文本编辑器中直接修改了文件,如何同步到WebView呢?很简单,在插件中添加一个监听就可以了: | |||
<syntaxhighlight lang="typescript"> | |||
// public async resolveCustomTextEditor( | |||
const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument((e) => { | |||
if (e.document.uri.toString() === document.uri.toString()) { | |||
updateWebview(); | |||
} | |||
}); | |||
webviewPanel.onDidDispose(() => { | |||
changeDocumentSubscription.dispose(); | |||
}); | |||
</syntaxhighlight> | |||
现在,可以实现双向同步的效果: | |||
[[Image:Prompt_IDE_Sync.gif|class=fixed-600]] | |||
=== 后台隐藏时Web页面销毁的问题 === | |||
经过以上处理后,大多数情况下,编辑器都能够正常工作。但是有一种情况例外:如果把WebView页面放到后台,这时候在文本编辑器中修改了内容,是无法同步过去的。 | |||
当WebView不可见时,Web页面的内容会被销毁,并且在WebView回到前台时重新加载<ref>https://code.visualstudio.com/api/extension-guides/webview#persistence</ref>。 | |||
因此,对于一个有状态的WebView,一旦隐藏到后台,由于页面销毁,消息通讯也就中断了。解决方案有两种: | |||
* 直接暴力使用<syntaxhighlight lang="bash" inline> retainContextWhenHidden </syntaxhighlight>选项阻止vscode销毁 | |||
* 通过<syntaxhighlight lang="bash" inline>getState</syntaxhighlight> 和 <syntaxhighlight lang="bash" inline>setState</syntaxhighlight>保存并重新加载状态,这是更推荐的做法 | |||
例如,采取简单暴力的做法,可以这样实现: | |||
<syntaxhighlight lang="typescript"> | |||
// src/PromptEditor.ts | |||
const providerRegistration = vscode.window.registerCustomEditorProvider( | |||
PromptEditor.viewType, | |||
provider, | |||
{ | |||
webviewOptions: { | |||
retainContextWhenHidden: true, | |||
}, | |||
} | |||
); | |||
</syntaxhighlight> | |||
= 一些Vs Code插件开发的经验 = | |||
== 如何快速调试WebView == | |||
由于引入了React作为UI引擎,正常的插件开发流程是这样: | |||
* 首先编译打包webview-ui:<syntaxhighlight lang="bash" inline>cd webview-ui && npm run build</syntaxhighlight> | |||
* 运行插件(F5) | |||
这样其实挺慢。由于webview-ui本身是一个完整的React工程,可以直接在浏览器上运行,与开发正常的React项目没有太大差别: | |||
<syntaxhighlight lang="bash"> | |||
cd webview-ui | |||
npm run start | |||
</syntaxhighlight> | |||
但是,如果要调试一些带有状态待页面,比如加载一个文档,那么就需要通过<syntaxhighlight lang="bash" inline>window.postMessage</syntaxhighlight>模拟vscode发送消息了: | |||
[[Image:Mock Message.png|600px]] | |||
== CSP 与自定义资源加载 == | |||
在WebView中并不是什么都能做,插件通过Content Security Policy (CSP)对css、js、字体、请求调用等做了限制。这相当于定义了一个白名单,指定那些资源可以在WebView中访问,当然这样做的目的主要是为了安全起见。 | |||
例如,微软提供的例子中是这样定义CSP的: | |||
<syntaxhighlight lang="html"> | |||
<!-- https://github.com/microsoft/vscode-extension-samples/blob/main/custom-editor-sample/src/catScratchEditor.ts --> | |||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${webview.cspSource}; style-src ${webview.cspSource}; script-src 'nonce-${nonce}';"> | |||
</syntaxhighlight> | |||
如果我们要调用外部的API,那么也必须要添加到CSP中,例如: | |||
<syntaxhighlight lang="bash"> | |||
connect-src https://api.openai.com https://api.minimax.chat; | |||
</syntaxhighlight> | |||
== 在插件中使用本地字体图片 == | |||
在Vs Code中如果WebView中使用了本地的图片,最终是通过一个https的链接访问的, | |||
例如,index.css文件我们是通过<syntaxhighlight lang="bash" inline>getUri</syntaxhighlight>方法获取到一个URI,然后加载到WebView中: | |||
<syntaxhighlight lang="typescript"> | |||
// src/PromptEditor.ts | |||
const getAsset = (name: string) => getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", name]); | |||
<link rel="stylesheet" type="text/css" href="${getAsset("index.css")}"> | |||
</syntaxhighlight> | |||
这样本地的资源最终会转换为一个https的虚拟地址来访问: | |||
<syntaxhighlight lang="bash"> | |||
https://file+.vscode-resource.vscode-cdn.net/Users/.../prompt-ide/webview-ui/build/assets/index.css | |||
</syntaxhighlight> | |||
对于图片和字体,都是如此。例如,如果我们在css中使用了一个图片: | |||
<syntaxhighlight lang="css"> | |||
.user-avatar { | |||
background-image: url(./user.png); | |||
} | |||
</syntaxhighlight> | |||
这样这个图片如果单独在React项目中运行是没有问题的,在Vs Code中运行就会显示不出来。 | |||
其实原理很简单:我们必须通过getUri来获取一个虚拟的地址,但是这个方法只有在插件中可用,在webview-ui中无法直接使用。 | |||
笔者解决这个问题的方法是,在插件的HTML中覆盖样式: | |||
<syntaxhighlight lang="html"> | |||
<!-- src/PromptEditor.ts --> | |||
<style nonce="${nonce}"> | |||
.user-avatar { | |||
background-image: url("${getAsset("user.png")}"); | |||
} | |||
</style> | |||
</syntaxhighlight> | |||
= 总结 = | |||
本文初略总结了一些Prompt IDE插件实现的原理和一些难点,虽然并没有十分复杂的技术点,但有一定的学习成本。 | |||
现在,Prompt IDE已经发布了pre-release供尝鲜,可以在Vs Code插件中搜索或者在[https://marketplace.visualstudio.com/items?itemName=riguz.prompt-ide Marketplace]上下载。 | |||
受限于笔者的水平、经验和精力,错误和不足之处在所难免,欢迎大家交流和PR。 |
2024年1月16日 (二) 11:41的最新版本
你是怎样调提示词的呢?这看起来是个再简单不过的问题,但对于开发者而言,一直缺乏一个趁手的工具。目前可以用的这些工具大致有以下的问题:
- 可以同大模型聊天,但是不具备调参能力,例如ChatGPT、文心一言、ChatHub等
- 可以调参,但只支持特定的厂商,例如GPT Playground、Minimax体验中心等
- 可以调参,也支持不同的厂商,功能也很强大,唯一缺点是不是原生的,如KPP
- 啥都可以干,但是要写代码,例如LangChain
于是为了解决这个问题,能够更愉快地测试提示词,笔者撸了一个Vs Code插件——Prompt IDE。目前,尽管缺胳膊少腿,Prompt IDE初具雏形。在这个有趣的过程中,学习到了Vs Code插件开发的一些技巧,也对Vs Code的强大有了更深的认知,在这里简单总结一下。
项目起源
这个想法最初源自于公司2023年7月份的黑客马拉松活动,当时,笔者Solo了一个叫做”Prompt Native“的项目。什么是Prompt Native呢?有三层含义[1]:
- Colud Native: 将大模型的能力做成云原生的FaaS
- Native Editor: 原生的提示词开发编辑器,也就是今天的Prompt IDE
- Native Tooling: 通过原生的大模型工具链来辅助我们开发
大致的设计是这样:
- 通过一个Vs Code插件来开发提示词,提示词最终存储为一个文件,有这特定的格式。这就是Native Editor
- 在开发提示词的过程中,可以通过大模型给出提示词调优的建议,类似与Js Lint,我们可以做一个Prompt Lint,不过当时经过测试发现效果并不好;还有比如自动生成人设,例如chatgpt-prompt-generator-v12通过一个预训练的BART模型自动生成提示词,也可以集成到插件中,做到自动补全。这是Native Tooling
- 提供不同语言的FaaS模板和解析运行Prompt文件的库,将其部署到Kubernetes中为云函数。当时采用Knative Functions和Rust实现了一个简单的Hello World模板
得益于GPT辅助编程,作为Rust菜鸟和第一次开发Vs Code插件的半吊子前端,肝几天就做出了一个PoC,看起来很简单。这是当时的Prompt IDE的最初版本:
想法其实也很简单:实现一个Side-by-side的提示词编辑功能,可以在界面上编辑运行,也可以直接改源码,这样就解决重口难调的问题,既方便又实用。
但是笔者当时犯了一个极大的错误,就是低估了这件事的难度。后来发现,要做到”可用“的程度,其实是非常困难的。
因此,接下来的时间断断续续在思考这件事,最终决定先放弃其他”Native",先把编辑器整清楚,从而产生了这个Prompt IDE。
Prompt IDE是如何运行的
从提示词存储格式开始
在函数模板中,提示词跟其他代码是同等重要的位置,因为提示词的变化也就意味着功能的变化。 因此,提示词最终需要存储为一个文本文件,这样才能跟版本控制等其他工具有机结合起来,也便于分享和协作。 但是,提示词应该怎么表示呢?
一些工具将提示词简单表示为一个文本,其中可能包含动态的内容(变量):
请将下面这句话翻译为{{language}}:
{{text}}
这看起来没有什么问题,在我们确定只用GPT的情况下。但是,如果我们需要使用不同的模型和参数呢?同样的提示词,在不同的模型和参数下,其运行结果是可能会有差别的。一种做法是,把这些信息也添加进去,例如:
-- Model=gpt-3.5-turbo-instruct Temperature=0.1
请将下面这句话翻译为{{language}}:
{{text}}
这样看起来不错,但是只适用于比较简单的补全(Completion)场景。补全是最早期的大模型调用方式,现在几乎已经被经过指令微调的对话(Chat)模式所取代。对于对话而言,会包含问答、少样本示例、参数等,当然我们也可以在之前的基础上稍作修改进行表示:
-- Model=gpt-3.5-turbo Temperature=0.1
System: 你是一只会模仿鹦鹉,从现在开始,我说什么,你跟着说什么
User: 记住了么?
Assistant:记住了么?
User: 停止,你现在不是鹦鹉了
Assistant:停止,你现在不是鹦鹉了
User: 好了,现在开始
但这样问题也很明显:因为没有严格的格式约束,很容易出错。在调用GPT的API时,我们是需要将其转换为messages数组来表示对话内容的,那么,如果这里面的格式有些问题,比如中英文的冒号不对,可能就会导致最终的结果不对。
我们需要有一种方式,可以来直观表示提示词,并且不容易出错的。
这里总共考虑过三种不同的格式:
- XML:标记语法,可以通过XML DTD来校验格式,且可以通过
<![CDATA[...]]>
的方式来包裹多行文本,对于多行的提示词十分友好 - YAML:也可以支持多行文本,缺点是没有标记,完全依靠缩进,容易乱
- JSON:可以通过JSON Schema进行校验,缺点是需要对多行、特殊字符进行转义
最终,经过一些尝试后,最终选择了使用JSON表示。主要原因是,JSON格式校验比较完善,且天然对前端友好,更适合在Vs Code这种基于Web的体系下开发。 在Vs Code中编辑JSON时,也可以设置Schema来实现编辑器自带的格式校验和自动补全。如下是通过JSON来表示一个提示词的例子:
{
"$schema": "../schema/chat-schema.json",
"version": "chat@0.2",
"engine": "chat-bison",
"messages": [
{
"role": "user",
"content": "Write a hello world in js"
}
],
"parameters": [
{
"name": "temperature",
"value": 0.1
}
]
}
对不同的大模型接口进行抽象
仅仅使用JSON是不够的,还需要对不同的大模型接口进行一次抽象。 例如,GPT中的System参数,在接口中是以一个特别的message表示的:
"messages": [
{
"role": "system",
"content": "Hello"
},
但是其他大模型并没有这个设计。除此之外,一些细微的地方,大家实现起来也各不相同,比如,大模型的回复,GPT叫”assistant",Minimax叫“BOT”。 所以,必须要对这些参数进行一次抽象,用一个统一的方式来表示提示词。目前的实现,主要参考了GPT和谷歌的AI Studio的做法。例如,通过context定义背景(类似GPT的System、Minimax的人设),通过examples定义Few-shot示例。 经过抽象后,最终形成一个JSON Schema表示,例如对话模式可以表示如下(部分):
{
"context": {
"type": "string"
},
"examples": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/definitions/message"
}
},
"messages": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/definitions/message"
}
}
}
选取开发框架
参照Vs Code的插件开发文档可以很容易创建一个插件。但是要做一个复杂的交互页面,还需要进一步对框架进行定制:
- 我们的场景其实是一个提示词编辑器,非常类似于WebView文档中的自定义编辑器的例子。因此,最佳的做法就是通过WebView实现一个网页来嵌入到Vs Code中
- 纯原生的WebView是手写HTML来操作的,这样生产力太低了。vscode-webview-ui-toolkit是一个Vs Code的控件集,并提供了集中前端框架的集成示例,例如Angular, React等
- 使用Typescript而不是Javascript,这样可以更好地进行静态类型检测
笔者采取的是React+vite的方式集成vscode-webview-ui-toolkit。 最终项目的结构大致如下:
- prompt-ide
- package.json
\ src
- extension.ts
\ webview-ui
- package.json
\ src
\ prompt-schema
- package.json
\ src
其中,prompt-schema为提示词格式的JSON Schema定义,通过dependency引用。webview-ui为WebView页面的实现,编译后嵌入插件中(index.js, index.css等)。 这样,其实相当于通过React生成了一个静态的HTML、JS等,然后在Vs Code插件中读取这个HTML,渲染一个Web页面,代码看起来有点不那么优雅:
// 省略了很多细节
export class PromptEditor implements vscode.CustomTextEditorProvider {
public async resolveCustomTextEditor(
document: vscode.TextDocument,
webviewPanel: vscode.WebviewPanel,
_token: vscode.CancellationToken
): Promise<void> {
webviewPanel.webview.html = this._getWebviewContent(webviewPanel.webview);
}
private _getWebviewContent(webview: Webview) {
const getAsset = (name: string) =>
getUri(webview, this.context.extensionUri, ["webview-ui","build","assets",name,]);
const nonce = getNonce();
return /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="${getAsset("index.css")}">
<title>Prompt Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" nonce="${nonce}" src="${getAsset("index.js")}"></script>
</body>
</html>
`;
}
}
值得一提的是,微软为了避免大家做的插件乱七八糟真是操碎了心,不仅提供了详细的 UX设计指导,每一个控件什么时候用,什么时候不该用也写的明明白白。
实现双向编辑同步
Prompt IDE的一个核心功能是双向同步。也就是说,在UI界面上的修改会改变文件内容;同时,改变文件内容也会同步到UI。 如果单纯只是在React中做一个编辑器,其实是十分简单的,我们可以通过一个State来维护当前编辑的提示词:
// webview-ui/src/App.tsx
function App() {
const [prompt, setPrompt] = useState<ChatPrompt | CompletionPrompt | null>(null);
}
加载文件内容
但是,在Vs Code中打开文件的时候,如何读取文档内容并通过setPrompt
初始化呢?
在上一节的代码中可以看到,如果在插件代码中,我们可以直接通过vscode.TextDocument
对象获取文本文件的内容:
// public async resolveCustomTextEditor(
// document: vscode.TextDocument, ← 看这里
// webviewPanel: vscode.WebviewPanel,
// _token: vscode.CancellationToken
// )
document.getText()
然而,在webview-ui中无法直接通过这种方式获取到文档内容,只能通过通信的方式来实现,其原理如下:
因此,我们需要通过两步来实现:
首先,在插件启动的时候通过消息发送文件的内容完成初始化:
// src/PromptEditor.ts
import * as vscode from "vscode";
export class PromptEditor implements vscode.CustomTextEditorProvider {
public async resolveCustomTextEditor(
document: vscode.TextDocument,
webviewPanel: vscode.WebviewPanel,
_token: vscode.CancellationToken
): Promise<void> {
const updateWebview = () => {
webviewPanel.webview.postMessage({
type: "update",
text: document.getText(),
});
};
updateWebview();
// ...
}
}
然后,在WebView中监听消息,加载文件内容
// webview-ui/src/App.tsx
function App() {
const messageListener = (event: MessageEvent<any>) => {
const message = event.data;
if (message.type == "update") {
try {
const prompt = loadPrompt(message.text);
setPrompt(prompt);
} catch (error) {
// ...
}
}
};
useEffect(() => {
window.addEventListener("message", messageListener);
return () => {
window.removeEventListener("message", messageListener);
};
}, []);
}
将改动同步到文件
在Web编辑器上对提示词的修改等同于修改prompt状态(setPrompt
),这里便不展开。
现在的问题是,如何把UI上面的修改同步到文件中呢?
从前面的消息模型可以知道,要想把WebView的变更同步到文档,也只能通过发消息的模式。 首先,在文档变更的时候,发送一个消息:
// webview-ui/src/App.tsx
import { vscode } from "./vscode";
function App() {
const [prompt, setPrompt] = useState<ChatPrompt | CompletionPrompt | null>(null);
const syncPrompt = (newPrompt: ChatPrompt | CompletionPrompt) => {
vscode.postMessage({
type: "sync",
text: JSON.stringify(newPrompt, null, 2),
});
};
const onPromptChanged = (newPrompt: ChatPrompt | CompletionPrompt) => {
syncPrompt(newPrompt);
setPrompt(newPrompt);
};
}
然后,在插件中监听消息,通过vscode的API完成文档更新:
// src/PromptEditor.ts
export class PromptEditor implements vscode.CustomTextEditorProvider {
public async resolveCustomTextEditor(
document: vscode.TextDocument,
webviewPanel: vscode.WebviewPanel,
_token: vscode.CancellationToken
): Promise<void> {
webviewPanel.webview.onDidReceiveMessage((e) => {
switch (e.type) {
case "sync":
this.updateTextDocument(document, e.text);
break;
default:
break;
}
});
}
private updateTextDocument(document: vscode.TextDocument, text: string) {
const edit = new vscode.WorkspaceEdit();
edit.replace(
document.uri,
new vscode.Range(0, 0, document.lineCount, 0), // 做全量更新,如果做的更好可以只更新文档的部分内容
text
);
return vscode.workspace.applyEdit(edit);
}
}
注意到,在App.tsx中我们同样引入了一个“vscode”(import { vscode } from "./vscode";
,不是说不能直接用么?
其实,这只是个障眼法,通过一个包装类屏蔽了Vscode和浏览器运行的差异:
// webview-ui/src/utilities/vscode.ts
class VSCodeAPIWrapper {
private readonly vsCodeApi: WebviewApi<unknown> | undefined;
constructor() {
if (typeof acquireVsCodeApi === "function") {
this.vsCodeApi = acquireVsCodeApi(); // 只有在vscode中运行时可以获取到,在浏览器直接运行时无法获取
}
}
public postMessage(message: unknown) {
if (this.vsCodeApi) {
this.vsCodeApi.postMessage(message);
} else {
console.log(message);
}
}
}
将改动同步到WebView
如果用户在Vs Code的文本编辑器中直接修改了文件,如何同步到WebView呢?很简单,在插件中添加一个监听就可以了:
// public async resolveCustomTextEditor(
const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument((e) => {
if (e.document.uri.toString() === document.uri.toString()) {
updateWebview();
}
});
webviewPanel.onDidDispose(() => {
changeDocumentSubscription.dispose();
});
现在,可以实现双向同步的效果:
后台隐藏时Web页面销毁的问题
经过以上处理后,大多数情况下,编辑器都能够正常工作。但是有一种情况例外:如果把WebView页面放到后台,这时候在文本编辑器中修改了内容,是无法同步过去的。 当WebView不可见时,Web页面的内容会被销毁,并且在WebView回到前台时重新加载[3]。
因此,对于一个有状态的WebView,一旦隐藏到后台,由于页面销毁,消息通讯也就中断了。解决方案有两种:
- 直接暴力使用
retainContextWhenHidden
选项阻止vscode销毁 - 通过
getState
和setState
保存并重新加载状态,这是更推荐的做法
例如,采取简单暴力的做法,可以这样实现:
// src/PromptEditor.ts
const providerRegistration = vscode.window.registerCustomEditorProvider(
PromptEditor.viewType,
provider,
{
webviewOptions: {
retainContextWhenHidden: true,
},
}
);
一些Vs Code插件开发的经验
如何快速调试WebView
由于引入了React作为UI引擎,正常的插件开发流程是这样:
- 首先编译打包webview-ui:
cd webview-ui && npm run build
- 运行插件(F5)
这样其实挺慢。由于webview-ui本身是一个完整的React工程,可以直接在浏览器上运行,与开发正常的React项目没有太大差别:
cd webview-ui
npm run start
但是,如果要调试一些带有状态待页面,比如加载一个文档,那么就需要通过window.postMessage
模拟vscode发送消息了:
CSP 与自定义资源加载
在WebView中并不是什么都能做,插件通过Content Security Policy (CSP)对css、js、字体、请求调用等做了限制。这相当于定义了一个白名单,指定那些资源可以在WebView中访问,当然这样做的目的主要是为了安全起见。
例如,微软提供的例子中是这样定义CSP的:
<!-- https://github.com/microsoft/vscode-extension-samples/blob/main/custom-editor-sample/src/catScratchEditor.ts -->
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${webview.cspSource}; style-src ${webview.cspSource}; script-src 'nonce-${nonce}';">
如果我们要调用外部的API,那么也必须要添加到CSP中,例如:
connect-src https://api.openai.com https://api.minimax.chat;
在插件中使用本地字体图片
在Vs Code中如果WebView中使用了本地的图片,最终是通过一个https的链接访问的,
例如,index.css文件我们是通过getUri
方法获取到一个URI,然后加载到WebView中:
// src/PromptEditor.ts
const getAsset = (name: string) => getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", name]);
<link rel="stylesheet" type="text/css" href="${getAsset("index.css")}">
这样本地的资源最终会转换为一个https的虚拟地址来访问:
https://file+.vscode-resource.vscode-cdn.net/Users/.../prompt-ide/webview-ui/build/assets/index.css
对于图片和字体,都是如此。例如,如果我们在css中使用了一个图片:
.user-avatar {
background-image: url(./user.png);
}
这样这个图片如果单独在React项目中运行是没有问题的,在Vs Code中运行就会显示不出来。 其实原理很简单:我们必须通过getUri来获取一个虚拟的地址,但是这个方法只有在插件中可用,在webview-ui中无法直接使用。 笔者解决这个问题的方法是,在插件的HTML中覆盖样式:
<!-- src/PromptEditor.ts -->
<style nonce="${nonce}">
.user-avatar {
background-image: url("${getAsset("user.png")}");
}
</style>
总结
本文初略总结了一些Prompt IDE插件实现的原理和一些难点,虽然并没有十分复杂的技术点,但有一定的学习成本。 现在,Prompt IDE已经发布了pre-release供尝鲜,可以在Vs Code插件中搜索或者在Marketplace上下载。
受限于笔者的水平、经验和精力,错误和不足之处在所难免,欢迎大家交流和PR。