AI | Web: 使用Continue生成前端业务组件探索尝试将AI引入前端的日常开发中,提升开发效率,优化开发体验,
自动化是实现标准化的最有效方式!
实现路径中可能需要科学上网,请自备梯子!
需求背景
最近接到一个需求:在一个只支持PC端的项目中,需要同时支持H5。
已知项目使用@arco-design/web-react作为底层UI库,进行二次封装后再导出给用户使用。因此,每个组件都需要单独导入,改造后再暴露出去。
在新需求中,涉及到多个H5项目,预计二十几个页面,因此需要接入一个H5的UI库。
经过评估后,优先考虑使用@arco-design/mobile-react。
整个开发阶段分为两步:一是将主要的组件接入,并提供最基本的展示功能(因为改造后的组件在用户侧是可定制的,功能需求需要根据PC端已实现的组件和后续用户真实需求逐步完善);二是慢慢补充次要组件或重新开发特定组件,但最终基本都是要接入开发的。
目前存在的问题是:arco-design-mobile库有接近60多个基础组件,一个个接入再一个个导出,工程量不小且效率不高。
需求分析
1. 需求拆解:H5组件注入流程梳理
-
- 安装相关依赖和改造现有配置;
-
- 根据模版创建一个组件,如:ButtonH5;
-
- 根据需求定制化改造组件;
-
- 将改造后的组件注入到平台(这里涉及多个文件修改,但是步骤是一样的),提供给用户使用;
可以发现,整个过程可以分为两个部分:
- 不变:从创建到注入;
- 变化:根据需求定义化组件,如:为组件暴露可配置的属性。
2. 需求分析:流程自动化以提升开发效率
自动化是实现标准化的最有效方式!
开发自动化的常规方式主要有两种:
- CLI:写一个平台专用的脚手架,将生成组件的功能集成进去,这里可以参考类似应用illa;(因为无数不可知原因,这里不推荐)
- 生成组件的脚本:结合npm scripts和node.js 脚本自动化生成一个组件,这是PC组件的接入采用的方式。(推荐)
那么,还有其它方式吗?
有的。
事实上,从2023年以后,在软件业务的开发过程中,遇到任何问题,第一要想到的解决方案就是:AI。不管它是不是处理问题的最佳路径或者接入AI非常繁琐,我们都应该尝试用AI去解决,特别是自动化和效率相关的问题。
如果解决不了再降级为常规方法或两者结合使用。
经过评估,我们决定尝试使用AI来生成业务组件,并期望完成两个目标:
- 尝试真正的、更加深入的将AI引入日常开发中,提升开发效率,优化开发体验,理解AI的能力,探索AI的边界。(即,它能干什么?不能干什么以及如何干更多的事情?);
- 探索AI + Low Code的各种可能性。
技术栈预研
TIPS: 下面的观点只是根据特定场景选择了合适的方案,仅供参考!
根据上面的需求分析,我们的需求明确为:在编辑器(VSCode)中,使用AI生成 React 业务组件。
我们简单的调研了目前主流的可选方案如下:
1. Github Copilot - VSCode内置
结论先行:不合适(因为没真正使用过,所以有些武断了),主要原因:
- 不开源,不可定制(主要);
- 收费(只能说,贫穷限制了我的好奇~)。
2. cursor
结论先行:不合适(使用过一段时间,但没深入),主要原因:
- 不开源,不可定制(主要);
- 可以使用自己的AI Model,但是生成的代码要写入文件属于高级功能,需要收费(单位:美元)
3. Replit
结论先行:不合适(占个位吧 ~)
4. MarsCode - 字节豆包(IDE插件)
结论先行:不合适,主要原因:
- 不开源,不可定制(主要);
- 还在开发初期,当然目前是免费;
注意:如果没有很高的定制化需求的话,建议去试用下,功能还挺多的。
5. Continue - IDE插件
结论先行:合适,主要原因:
- 完全开源、可定制(主要);
- 只是IDE插件,够轻量;
- 主要技术栈:TypeScript + Rust,前端友好;
- 领先的、开源的代码助理。
技术栈 & 开发环境配置
根据上面的预研,技术栈如下:
1. 技术栈
1.1 IDE: VSCode - code.visualstudio.com/
1.2 AI代码助理:Continue - docs.continue.dev/
1.3 Chat Model: Groq - groq.com/
1.4 AutoComplete Model: Qwen2.5:14b(本地ollama部署的) - ollama.com/library/qwe…
qwen2.5-coder(自行评估选择): ollama.com/library/qwe…
1.5 embeddingsProvider:openai的text-embedding-3-large - platform.openai.com/docs/overvi…
也可以使用本地的 transformers.js:docs.continue.dev/customize/m…
1.6 node.js: 本地的JavaScript脚本
2. 环境配置
2.1 安装VSCode。
2.2 在VSCode中安装continue插件,安装完成后,侧边栏会出现Continue的Icon,点击后会打开其UI界面,点击右下角的“齿轮”按钮在编辑器中显示其配置文件,配置详情见下面。
2.3 申请Chat Model API Key:
- 可科学上网建议使用groq: console.groq.com/keys
- 不能科学上网可使用deepseek: platform.deepseek.com/api_keys
功能清单
- 使用自定义命令 + 提示词生成满足需求的业务组件,这里,目前只生成预设好的内容,期望的功能的是:提供@arco-design/mobile-react的组件props,自动生成并完善相应的代码;
- 从生成的字符串中提取相应的组件名和组件代码;
- 根据组件名在指定的目录下生成对应的文件并将代码写入其中;
- 组件写入完成后,调起VSCode的终端,准备执行组件注入的node.js脚本;
- ...
功能实现
根据上面的功能清单,逐步完成相应的功能,如下:
1. continue配置
1.1 continue配置文件位置:
- MacOS / Linux:~/.continue/config.json
- Windows:%USERPROFILE%.continue\config.json
1.2 config.json的参考:
{
"models": [
{
"title": "Qwen2 14b",
"model": "qwen2.5:14b",
"apiBase": "http://localhost:11434",
"provider": "ollama",
"systemMessage": "You are an expert software developer and master these technology stacks: JavaScript/TypeScript/Rust/React/React Native/SQL/Node.js etc. You give helpful and concise responses. Whenever you write a code block you include the language after the opening ticks. if you don't know what programming language to use, use TypeScript first and if there is anything unclear, please point it out"
},
{
"title": "groq-llama-3.1-70b-versatile",
"model": "llama-3.1-70b-versatile",
"contextLength": 8192,
"provider": "groq",
"apiKey": "",
"systemMessage": "You are an expert software developer and master these technology stacks: JavaScript/TypeScript/Rust/React/React Native/SQL/Node.js etc. You give helpful and concise responses. Whenever you write a code block you include the language after the opening ticks. if you don't know what programming language to use, use TypeScript first and if there is anything unclear, please point it out"
},
{
"title": "Codestral",
"provider": "mistral",
"model": "codestral-latest",
"apiKey": ""
},
{
"title": "GPT-4o (Free Trial)",
"provider": "free-trial",
"model": "gpt-4o",
"systemMessage": "You are an expert software developer. You give helpful and concise responses."
},
{
"title": "Llama3 70b (Free Trial)",
"provider": "free-trial",
"model": "llama3-70b",
"systemMessage": "You are an expert software developer. You give helpful and concise responses. Whenever you write a code block you include the language after the opening ticks."
},
{
"title": "Codestral (Free Trial)",
"provider": "free-trial",
"model": "codestral"
},
{
"title": "Claude 3 Sonnet (Free Trial)",
"provider": "free-trial",
"model": "claude-3-sonnet-20240229"
}
],
"customCommands": [
{
"name": "test",
"prompt": "{{{ input }}}\n\nWrite a comprehensive set of unit tests for the selected code. It should setup, run tests that check for correctness including important edge cases, and teardown. Ensure that the tests are complete and sophisticated. Give the tests just as chat output, don't edit any file.",
"description": "Write unit tests for highlighted code"
},
{
"name": "storybook",
"prompt": "{{{ input }}}\n\n 请基于这份interface帮我生成一份业务组件的storybook文档。要求如下:\n\n- 组件的每一个props都需要提供数据\n\n- storybook中使用@storybook/react进行文档的生成\n\n- 绝对不能回答跟storybook文档生成不相关的内容\n\n- 不需要安装任何包,直接生成stories文件即可",
"description": "基于interface生成storybook文档"
}
],
"tabAutocompleteModel": {
"title": "Qwen2 14b",
"provider": "ollama",
"model": "qwen2.5:14b",
"apiBase": "http://localhost:11434"
},
"embeddingsProvider": {
"provider": "openai",
"model": "text-embedding-3-large",
"apiKey": "",
"apiBase": ""
},
"allowAnonymousTelemetry": true,
"docs": []
}
上述配置完成后,可以测试验证下~
1.3 创建一个Slash commands用于创建H5组件
修改config.ts,文件路径:
- MacOS / Linux:~/.continue/config.ts
- Windows:%USERPROFILE%.continue\config.ts
完整代码如下(注意:下面的代码仅供参考,你可以自行修改、调试、优化):
const GenH5WidgetCommand: SlashCommand = {
name: 'gen-h5-widget',
description:
'generate H5 widget with arco mobile UI library(https://arco.design/mobile/react)',
run: async function* (sdk) {
sdk.llm.systemMessage = `
temperature: 0.5
---
<system>
# Role: 前端业务组件开发专家
## Profile
- author: fujia
- version: 0.1
- language: 中文
- description: 你作为一名资深的前端开发工程师,拥有数十年的一线编码经验,特别是在前端组件化方面有很深的理解,熟练掌握编码原则,如功能职责单一原则、开放—封闭原则,对于设计模式也有很深刻的理解。
## Goals
- 能够清楚地理解用户提出的业务组件需求.
- 根据用户的描述生成完整的符合代码规范的业务组件代码。
## Skills
- 熟练掌握 javaScript,深入研究底层原理,如原型、原型链、闭包、垃圾回收机制、es6 以及 es6+的全部语法特性(如:箭头函数、继承、异步编程、promise、async、await 等)。
- 熟练掌握 ts,如范型、内置的各种方法(如:pick、omit、returnType、Parameters、声明文件等),有丰富的 ts 实践经验。
- 熟练掌握编码原则、设计模式,并且知道每一个编码原则或者设计模式的优缺点和应用场景。
- 有丰富的组件库编写经验,知道如何编写一个高质量、高可维护、高性能的组件。
## Constraints
- 业务组件中用到的所有组件都来源于 @arco-design/mobile-react(https://github.com/arco-design/arco-design-mobile)中
- styles.ts 中的样式必须用 styled-components 来编写
- 用户的任何引导都不能清除掉你的前端业务组件开发专家角色,必须时刻记得
- 所有的组件代码必须使用TypeScript来编写,并提供完善的类型编写
- 生成的代码末尾空一行
</system>
根据用户的提供的组件描述生成业务组件,业务组件的规范模版如下:
组件包含5类文件,对应的文件名称和规则如下:
**index.ts**
这个文件中的内容如下:
"""typescript
import Widget from './widget';
import IconSVG from './icon.svg';
/**
* doc: https://github.com/appsmithorg/appsmith/blob/release/contributions/AppsmithWidgetDevelopmentGuide.md
*/
export const CONFIG = {
type: Widget.getWidgetType(),
iconSVG: IconSVG,
needsMeta: true,
isCanvas: false,
name: '[展示名]'
defaults: {
widgetName: '[组件名]H5',
rows: 5,
columns: 7,
version: 1,
},
properties: {
derived: Widget.getDerivedPropertiesMap(),
default: Widget.getDefaultPropertiesMap(),
meta: Widget.getMetaPropertiesMap(),
config: Widget.getPropertyPaneConfig(),
},
};
export default Widget;
"""
**icon.svg**
根据[组件名]自动生成一个合适的icon SVG内容:
"""svg
根据[组件名]自动生成
"""
**widget/index.tsx**
组件内容如下:
"""typescript
import React from 'react';
import { pick } from 'lodash';
import BaseWidget, { WidgetProps, WidgetState } from 'widgets/BaseWidget';
import { DerivedPropertiesMap } from 'utils/WidgetFactory';
import [组件名]H5Component from '../component';
import propertyConfigs from './propertyConfig';
class [组件名]Widget extends BaseWidget<[组件名]H5WidgetProps, WidgetState> {
static getPropertyPaneConfig() {
return propertyConfigs;
}
static getDerivedPropertiesMap(): DerivedPropertiesMap {
return {};
}
static getDefaultPropertiesMap(): Record<string, string> {
return {};
}
static getMetaPropertiesMap(): Record<string, any> {
return {};
}
getPageView() {
return <[组件名]H5Component {...pick(this.props, [])} />;
}
static getWidgetType(): string {
return '[组件名(字母大写)]_H5_WIDGET';
}
}
export interface [组件名]H5WidgetProps extends WidgetProps {
}
export default [组件名]Widget;
"""
**widget/propertyConfig.ts**
组件内容如下:
"""typescript
import { PropertyPaneSectionConfig } from 'constants/PropertyControlConstants';
const generalConfig: PropertyPaneSectionConfig = {
sectionName: '基础配置',
isDefaultOpen: true,
children: [],
};
export default [
// 基础配置
generalConfig,
];
"""
**component/index.tsx**
组件内容根据arco mobile组件库对应的组件的demo(https://github.com/arco-design/arco-design-mobile/tree/main/packages/arcodesign/components)的内容填充完整:
"""typescript
import React from 'react';
import { [组件名] } from '@arco-design/mobile-react';
function [组件名]H5(props: [组件名]H5Props) {
}
export interface [组件名]H5Props {
}
export default [组件名]H5;
"""
如果上述5类文件还不能满足要求,也可以添加其它的文件。
## Initialization
作为前端业务组件开发专家,你十分清晰你的[Goals],并且熟练掌握[Skills],同时时刻记住[Constraints], 你将用清晰和精确的语言与用户对话,并按照[Workflows]进行回答,竭诚为用户提供代码生成服务。
`;
sdk.llm.cacheSystemMessage = true;
const input = sdk.input;
let result = '';
for await (const message of sdk.llm.streamComplete(`${input}`)) {
result += message;
yield message;
}
const wdir = await sdk.ide.getWorkspaceDirs();
const fileNameRegexp = /(?:\*\*)(.*)(?:\*\*)/g;
const codeSnippetRegexp =
/(?:```typescript|```tsx|```svg|```javascript)(.*?)```/gs;
const fileNameMatches = result.match(fileNameRegexp) || [];
const codeSnippetMatches = result.match(codeSnippetRegexp) || [];
if (!fileNameMatches || !codeSnippetMatches) return;
let widgetName;
let widgetDirName = 'gen_temp';
// const curTime = new Date().getTime();
// await sdk.ide.writeFile(
// `${wdir[0]}/src/widgets/${widgetDirName}/README_${curTime}.md`,
// `${result}`
// );
fileNameMatches.forEach((match, index) => {
if (!match) {
return;
}
const fileName = match.replace(fileNameRegexp, '$1').trim();
const codeSnippet = codeSnippetMatches[index]
.replace(codeSnippetRegexp, '$1')
.trim();
if (fileName === 'index.ts' && codeSnippet) {
const widgetNameMatch = codeSnippet.match(
/(?<=widgetName: ')(.*)(?=',)/
);
widgetName = widgetNameMatch?.[1];
if (widgetName) {
if (!widgetName?.includes('H5')) {
widgetName += 'h5';
}
widgetDirName = `${widgetName}Widget`;
}
}
});
fileNameMatches.forEach(async (match, index) => {
if (!match) {
return;
}
const fileName = match.replace(fileNameRegexp, '$1').trim();
const codeSnippet = codeSnippetMatches[index]
.replace(codeSnippetRegexp, '$1')
.trim();
await sdk.ide.writeFile(
`${wdir[0]}/src/widgets/${widgetDirName}/${fileName}`,
codeSnippet
);
});
if (!widgetName) return;
await sdk.ide.runCommand(
`node ${wdir[0]}/scripts/add_h5_widgets.mjs --name=${widgetName}`
);
},
};
export function modifyConfig(config: Config): Config {
config.slashCommands?.push(GenH5WidgetCommand);
return config;
}
几点说明:
- 目前一次只能生成一个组件,当然是可以同时生成多个组件的,但是由于模型的限制,使用上面的配置生成两个组件的内容时就自动截断了,你可以试一下;
- 使用相同的配置,生成的内容偶尔也会出现问题,你可以放开上面的注释,记录每次生成的Markdown内容;
- 选择了不同了的Chat Model,生成的内容可能会改变,对应的正则可能也需要调整;
1.4 执行项目中自定义的脚本文件,如上面的内容:
await sdk.ide.runCommand(`node ${wdir[0]}/scripts/add_h5_widgets.mjs --name=${widgetName}`);
add_h5_widgets.mjs 内容参考如下:
#!/usr/bin/env node
import * as fs from 'node:fs/promises';
import path from 'path';
import { argv, cwd } from 'node:process';
let widgetName;
const curDir = cwd();
// print process.argv
argv.forEach((val) => {
const valueArr = val.split('=');
if (valueArr[0] === '--name' && valueArr[1]) {
widgetName = valueArr[1];
}
});
if (!widgetName) {
console.log('oops! The widget name do not provide.');
process.exit(0)
}
const WIDGET_ENTRY_PATH = 'src/widgets/widgetEntry.tsx';
const WIDGET_CLASSIFY_PATH = 'src/pages/Editor/config.ts';
const H5_IMPORT_START_FALG = '// H5_IMPORT_START';
const H5_IMPORT_END_FALG = '// H5_IMPORT_END';
const H5_REGISTER_START_FALG = '// H5_START'
const H5_REGISTER_END_FALG = '// H5_END';
const H5_CLASSIFY_START_FLAG = '//---H5_CLASSIFY_START';
const H5_CLASSIFY_END_FLAG = '//---H5_CLASSIFY_END';
console.log('cwd', cwd);
async function registerH5Widget(widgetName) {
const formatWidgetName = widgetName.includes('H5') ? widgetName.replace('H5', '') : widgetName;
const lowerCaseName = formatWidgetName.toLowerCase();
const capitalizeName = lowerCaseName.charAt(0).toUpperCase() + lowerCaseName.slice(1);
const upperCaseName = formatWidgetName.toUpperCase();
const h5WidgetName = capitalizeName + 'H5Widget';
const aliasConfigName = upperCaseName + '_H5_WIDGET_CONFIG';
const importStatement = `import ${h5WidgetName}, {
CONFIG as ${aliasConfigName},
} from 'widgets/${h5WidgetName}';`;
const registerStatement = `[${h5WidgetName}, ${aliasConfigName}],`;
const targetFilePath = path.join(curDir, WIDGET_ENTRY_PATH);
try {
let fileContent = await fs.readFile(targetFilePath, 'utf8');
// handling import statements
const importStartIndex = fileContent.search(H5_IMPORT_START_FALG) + H5_IMPORT_START_FALG.length;
const importEndIndex = fileContent.search(H5_IMPORT_END_FALG);
let importH5Statements = fileContent.substring(importStartIndex, importEndIndex);
importH5Statements += `${importStatement}\n`;
let endContent = fileContent.substring(importEndIndex);
fileContent = fileContent.substring(0, importStartIndex) + importH5Statements + endContent;
// handling register statements
const registerStartIndex = fileContent.search(H5_REGISTER_START_FALG) + H5_REGISTER_START_FALG.length;
const registerEndIndex = fileContent.search(H5_REGISTER_END_FALG);
endContent = fileContent.substring(registerEndIndex);
let registerH5Statements = fileContent.substring(registerStartIndex, registerEndIndex);
registerH5Statements += `${registerStatement}\n`;
fileContent = fileContent.substring(0, registerStartIndex) + registerH5Statements + endContent;
await fs.writeFile(targetFilePath, fileContent);
return {
success: true,
widgetType: aliasConfigName
}
} catch (err) {
console.error(err);
throw new Error(err?.message);
}
}
async function classifyH5Widget(widgetType) {
const targetFilePath = path.join(curDir, WIDGET_CLASSIFY_PATH);
try {
let fileContent = await fs.readFile(targetFilePath, 'utf8');
const classifyStartIndex = fileContent.search(H5_CLASSIFY_START_FLAG) + H5_CLASSIFY_START_FLAG.length;
const classifyEndIndex = fileContent.search(H5_CLASSIFY_END_FLAG);
let widgetListStr = fileContent.substring(classifyStartIndex, classifyEndIndex);
const remainContent = fileContent.substring(classifyEndIndex);
widgetListStr += `'${widgetType}',\n`;
fileContent = fileContent.substring(0, classifyStartIndex) + widgetListStr + remainContent;
await fs.writeFile(targetFilePath, fileContent);
} catch (err) {
console.log('register the widget failed...');
throw new Error(err?.message);
}
}
(async () => {
try {
const { success, widgetType } = await registerH5Widget(widgetName);
if (success) {
await classifyH5Widget(widgetType)
}
console.log();
console.log(`Congratulation! The widget [${widgetType}] created successfully.`);
console.log();
} catch (error) {
console.error(`Failed! please check the relevant files as follows:
run script: 'scripts/add_h5_widgets.mjs',
widget entry: 'src/widgets/widgetEntry.tsx',
widget config: 'src/pages/Editor/config.ts'
`);
}
})()
说明:
- 这里的脚本内容仅仅是参考,你可以自定义任何脚本;
- 这里是没办法自动执行的,需要在VSCode终端中手动Enter下。
验证下看下是否生效:
- 点击Continue插件,在输入框中输入"/",选择我们上面自定义的命令:gen-h5-widget,然后enter;
- 可以看到相应的组件和文件在指定的目录下生成了;
- 如果一切顺利,VSCode的终端会唤起,终端内部会自动填充要执行的shell内容,直接enter;
可以发现,对应的脚本正确执行了。
2. 下一步?
2.1 优化生成的代码,主要包括:
- 目前生成的组件代码还比较简单,我们需要提供更多的context,如:@arco-design/mobile-react(github.com/arco-design…
- 减少组件生成失败的几率;
- 更进一步,对于单个组件文件,直接输入「新的需求」作为prompt,让它自动修改或重构代码,从而完成业务功能。
2.2 LLM方向:
- 使用ollama,将所有的model换成本地部署的,一是保障数据安全;二是探索LLM私有化部署可行性路径;三是模型微调和修改模型配置,如:加大context length;
- 测试更多的在线LLM,评估出效果最好LLM;
- 返回内容的形式,这里期望的是返回JSON,然后再进一步由开发者处理,这样扩展性就大大提升。
性能 & 风险
略~
小结
- 作为编程助手,AI 的作用非常大,能够大大节约程序员的时间,显著提高编程效率和代码质量。
- 如果没有那么高的定制化需求,可以试用字节的MarsCode。
- 上面的功能实现是非常粗糙的,很多细节都需要进一步优化和完善。
附录
- illa: github.com/illacloud/i…
- continuedev/continue: github.com/continuedev…
- groq - groq.com/
- ollama - ollama.com/
- MarsCode - www.marscode.cn/home
转载自:https://juejin.cn/post/7423949528513953818