175 lines
12 KiB
JavaScript
175 lines
12 KiB
JavaScript
/**
|
||
* 无人值守 UI 验证:用本地静态服务器托管已构建的渲染产物,
|
||
* 注入 window.ycode 假桥(真实主进程不可用时的替身),供浏览器读取布局。
|
||
*
|
||
* 用法:node scripts/verify-ui.mjs [port]
|
||
* 打开 http://127.0.0.1:<port>/ 后用 CDP/浏览器读取 .app-shell 网格列宽。
|
||
*/
|
||
|
||
import { createServer } from 'node:http'
|
||
import { readFile } from 'node:fs/promises'
|
||
import { extname, join, normalize } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { chatParityFixture } from './fixtures/chat-parity.mjs'
|
||
import { sourceRecoveryFixture } from './fixtures/source-recovery.mjs'
|
||
import { dialogueMediaFixture } from './fixtures/dialogue-media.mjs'
|
||
import { typographyFixture, typographyProbes } from './fixtures/typography.mjs'
|
||
import { conversationCompleteFixture, conversationCompleteBridge } from './fixtures/conversation-complete.mjs'
|
||
import { artifactSourceFixture } from './fixtures/artifact-source.mjs'
|
||
import { pierreDiffFixture } from './fixtures/pierre-diff.mjs'
|
||
import { conversationStatesFixture, conversationStatesBridge } from './fixtures/conversation-states.mjs'
|
||
|
||
const root = fileURLToPath(new URL('../out/renderer', import.meta.url))
|
||
const port = Number(process.argv[2] ?? 8788)
|
||
|
||
const MIME = {
|
||
'.html': 'text/html; charset=utf-8',
|
||
'.js': 'text/javascript; charset=utf-8',
|
||
'.css': 'text/css; charset=utf-8',
|
||
'.json': 'application/json; charset=utf-8',
|
||
'.svg': 'image/svg+xml',
|
||
'.png': 'image/png',
|
||
'.woff2': 'font/woff2',
|
||
'.woff': 'font/woff',
|
||
'.ttf': 'font/ttf'
|
||
}
|
||
|
||
/**
|
||
* 假桥:只提供渲染进程首屏挂载需要的调用,其余返回空数组/空对象/undefined。
|
||
*
|
||
* 形状对齐 src/preload/index.ts 暴露的 window.ycode;主窗口(index.html)与设置窗口
|
||
* (settings.html / index.html?window=settings)共用同一份假桥。命名空间清单来自启动路径:
|
||
* App.tsx、stores/chat.ts、stores/settings.ts、ChatPane.tsx、Sidebar.tsx、
|
||
* SettingsDialog.tsx、settings.tsx、TitleBar.tsx、GeneralSettingsPanel.tsx、
|
||
* useComposerConnectors.ts、ComposerContextInfo.tsx。
|
||
*/
|
||
const STUB = `<script>
|
||
(function () {
|
||
function generalView() { return { autoLaunch: false, preventSleep: true, preventSleepActive: false, dataDir: '', dataBytes: 0, dataUsagePartial: false, disk: { totalBytes: 0, freeBytes: 0 }, workspaceRoot: '' } }
|
||
function personalizationView() { return { userCallName: '', aiName: '', tone: 'default', loadingTips: true, showFileChanges: false, customPrompt: '', persona: '', longTermMemory: '' } }
|
||
function memoryView() { return { conversationMemory: true, localMemorySkills: true, content: '' } }
|
||
function modelRegistry() { return { models: [], selectedModelId: null } }
|
||
function workspaceRegistry() { return { workspaces: [], selectedWorkspaceId: null } }
|
||
function sessionRecord(id) { return { id: id || 'offline-session', title: '新对话', createdAt: Date.now(), updatedAt: Date.now() } }
|
||
function fixtureValue(key, fallback) { var value = window.__YCODE_FIXTURE__ ? window.__YCODE_FIXTURE__[key] : undefined; return value === undefined ? fallback : value }
|
||
window.ycode = {
|
||
window: { minimize(){}, toggleMaximize(){}, close(){}, getState: async()=>({maximized:false,fullscreen:false}), onState:()=>()=>{}, onFocus:()=>()=>{} },
|
||
edit: { action: async()=>{} },
|
||
app: {
|
||
info: async()=>({name:'YCode',version:'0.1.0',electron:'37',chromium:'',node:'',configDir:'',claude:{available:false,exePath:null,sessionDir:''}}),
|
||
openConfigDir: async()=>{},
|
||
// 原版由主进程另开设置窗口(settings.html?window=settings);离线替身在同一标签内切换。
|
||
openSettings: async(section,action)=>{ try { const url=new URL('settings.html?window=settings',location.href); if(section) url.searchParams.set('section',String(section)); if(action) url.searchParams.set('action',String(action)); location.assign(url.href) } catch {} },
|
||
settingsReady: async()=>{},
|
||
settingsRendered: async()=>{},
|
||
onSettingsNavigate: ()=>()=>{},
|
||
onSettingsClosed: ()=>()=>{},
|
||
onSettingsChanged: ()=>()=>{}
|
||
},
|
||
config: { get: async()=>({baseUrl:'',model:'',hasApiKey:false,keyStorage:'plain',maxToolRounds:8}), update: async(p)=>({...p,hasApiKey:false,keyStorage:'plain'}) },
|
||
general: { get: async()=>generalView(), update: async()=>generalView(), changeWorkspaceRoot: async()=>generalView(), openFolder: async()=>{}, openSystemNotificationSettings: async()=>{} },
|
||
personalization: { get: async()=>personalizationView(), update: async()=>personalizationView() },
|
||
agents: { get: async()=>({teamManagement:true}), update: async()=>({teamManagement:true}) },
|
||
memory: { get: async()=>memoryView(), update: async()=>memoryView(), import: async()=>memoryView() },
|
||
models: {
|
||
list: async()=>modelRegistry(), save: async()=>modelRegistry(), remove: async()=>modelRegistry(), select: async()=>modelRegistry(),
|
||
fetch: async()=>({models:[]}), test: async()=>({ok:true}), metadata: async()=>({}), metadataStatus: async()=>null, refreshMetadata: async()=>null
|
||
},
|
||
workspace: {
|
||
get: async()=>({root:'C:\\\\Users\\\\Administrator\\\\code\\\\YCode'}),
|
||
choose: async()=>({root:''}),
|
||
list: async()=>workspaceRegistry(), generate: async()=>workspaceRegistry(), attach: async()=>({attachedWorkspaceId:undefined,...workspaceRegistry()}),
|
||
select: async()=>workspaceRegistry(), rename: async()=>workspaceRegistry(), remove: async()=>workspaceRegistry(), open: async()=>{}
|
||
},
|
||
session: {
|
||
list: async()=>structuredClone(fixtureValue('sessions',[])),
|
||
listArchived: async()=>structuredClone(fixtureValue('archivedSessions',[])),
|
||
create: async()=>sessionRecord('offline-session'),
|
||
updateContext: async(id)=>sessionRecord(id),
|
||
messages: async(id)=>structuredClone(fixtureValue('messages',{})[id] ?? []),
|
||
contextUsage: async()=>({}),
|
||
rename: async()=>{}, remove: async()=>{}, setPinned: async(id)=>sessionRecord(id), setArchived: async(id)=>sessionRecord(id),
|
||
openFolder: async()=>{}, openWindow: async()=>{}
|
||
},
|
||
chat: {
|
||
send: async(payload)=>({runId:'offline-run',assistantMessageId:'offline-assistant',modelId:'',modelName:'离线假桥',userMessage:{id:'offline-user-message',role:'user',content:payload&&payload.text,createdAt:Date.now(),status:'done'}}),
|
||
cancel: async()=>{}, setSessionVisible: async()=>({accepted:true}), onEvent:()=>()=>{}, onStarted:()=>()=>{},
|
||
activeRun: async()=>null, inspectEdit: async()=>({editable:false,reason:'离线假桥不支持就地编辑'}),
|
||
revert: async()=>({messages:[]}), respond: async()=>({accepted:false}), feedback: async()=>{}
|
||
},
|
||
experts: { list: async()=>[], read: async()=>({}), activate: async()=>({}), remove: async()=>{}, openPath: async()=>{} },
|
||
skills: { list: async()=>[], importPath: async()=>{}, read: async()=>({}), setEnabled: async()=>({}), remove: async()=>{}, export: async()=>{} },
|
||
mcp: { list: async()=>[], save: async()=>({}), trust: async()=>({}), connect: async()=>({}), disconnect: async()=>({}), setEnabled: async()=>({}), setToolEnabled: async()=>({}), remove: async()=>{} },
|
||
projects: { list: async()=>[], create: async()=>({projectId:'offline-project',name:'离线项目',instructions:'',createdAt:0,updatedAt:0}), update: async()=>({projectId:'offline-project',name:'离线项目',instructions:'',createdAt:0,updatedAt:0}) },
|
||
automation: { list: async()=>[], listRuns: async()=>[], create: async()=>({}), update: async()=>({}), setEnabled: async()=>({}), remove: async()=>{}, runNow: async()=>({}), onChanged:()=>()=>{} },
|
||
library: { getSnapshot: async()=>({artifacts:[],generatedAt:Date.now()}), setFavorite: async()=>({version:1,favoriteArtifactIds:[],recent:[]}), recordRecent: async()=>({version:1,favoriteArtifactIds:[],recent:[]}) },
|
||
attachments: { pick: async()=>[], stage: async(input)=>({id:'offline-attachment',name:(input&&input.name)||'',mimeType:(input&&input.mimeType)||'',size:0}), preview: async()=>undefined, remove: async()=>{} },
|
||
speech: { transcribe: async()=>({text:''}) },
|
||
git: { branch: async()=>({repo:false,branch:'',unavailable:true}) },
|
||
browser: {
|
||
create: async()=>({id:'offline-browser',title:'离线浏览器',url:'about:blank'}),
|
||
get: async(id)=>({id:id,title:'',url:'about:blank',canGoBack:false,canGoForward:false,loading:false}),
|
||
navigate: async()=>({}), back: async()=>({}), forward: async()=>({}), reload: async()=>({}), bounds: async()=>{}, close: async()=>{}, rebind: async()=>{}
|
||
},
|
||
terminal: {
|
||
create: async()=>({id:'offline-terminal',title:'终端'}),
|
||
get: async(id)=>({id:id,sequence:0,exited:false}),
|
||
write: async()=>{}, resize: async()=>{}, clear: async()=>{}, close: async()=>{}, rebind: async()=>{}, onEvent:()=>()=>{}
|
||
},
|
||
resources: {
|
||
list: async()=>[],
|
||
openExternal: async(url)=>{document.documentElement.dataset.fixtureExternal=url},
|
||
classify: async(path)=>path==='src'?'directory':window.__YCODE_FIXTURE__?.files?.[path]!==undefined?'file':'unknown',
|
||
resolveImage: async(source)=>window.__YCODE_FIXTURE__?.images?.[source] ?? (/^(https?:|data:|blob:)/.test(source)?source:undefined),
|
||
read: async(path,range)=>{
|
||
if(path==='src') return {kind:'directory',path,name:'src',entries:[{name:'example.ts',path:'src/example.ts',type:'file'}],truncated:false};
|
||
const content=window.__YCODE_FIXTURE__?.files?.[path];
|
||
if(content===undefined) throw new Error('Offline fixture: file not found');
|
||
return {kind:'text',path,name:path.split('/').pop(),content,firstLine:1,totalLines:content.split('\\n').length,range,truncated:false};
|
||
},
|
||
readText: async(path)=>{
|
||
document.documentElement.dataset.fixtureReadText=path;
|
||
const content=window.__YCODE_FIXTURE__?.files?.[path];
|
||
const delay=window.__YCODE_FIXTURE__?.delays?.[path];
|
||
if(delay) await new Promise(resolve=>setTimeout(resolve,delay));
|
||
if(content===undefined) throw new Error('Offline fixture: file not found');
|
||
return content;
|
||
},
|
||
download: async(payload)=>{document.documentElement.dataset.fixtureDownload=JSON.stringify({filename:payload.filename,mimeType:payload.mimeType,encoding:payload.encoding,length:payload.data.length,prefix:payload.data.slice(0,24)});return {saved:true,path:'offline-fixture/'+payload.filename}},
|
||
downloadImage: async(source)=>{document.documentElement.dataset.fixtureImageDownload=source.slice(0,48);return {saved:true,path:'offline-fixture/image'}}
|
||
}
|
||
};
|
||
})();
|
||
</script>`
|
||
|
||
const server = createServer(async (req, res) => {
|
||
try {
|
||
const urlPath = decodeURIComponent((req.url ?? '/').split('?')[0])
|
||
const rel = urlPath === '/' ? '/index.html' : urlPath
|
||
const filePath = normalize(join(root, rel))
|
||
if (!filePath.startsWith(root)) {
|
||
res.writeHead(403).end('forbidden')
|
||
return
|
||
}
|
||
let body = await readFile(filePath)
|
||
if (rel.endsWith('.html')) {
|
||
const fixture = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('fixture')
|
||
const selectedFixture = fixture === 'conversation-states' ? conversationStatesFixture : fixture === 'pierre-diff' ? pierreDiffFixture : fixture === 'artifact-source' ? artifactSourceFixture : fixture === 'conversation-complete' ? conversationCompleteFixture : fixture === 'chat-parity' ? chatParityFixture : fixture === 'source-recovery' ? sourceRecoveryFixture : fixture === 'dialogue-media' ? dialogueMediaFixture : fixture === 'typography' ? typographyFixture : null
|
||
const fixtureScript = selectedFixture
|
||
? `<script>window.__YCODE_FIXTURE__=${JSON.stringify(selectedFixture).replace(/</g, '\\u003c')};Object.defineProperty(navigator,'clipboard',{value:{writeText:async(text)=>{window.__fixtureCopied=text}},configurable:true});</script>`
|
||
: ''
|
||
// 在首个 module script 之前注入假桥
|
||
body = Buffer.from(body.toString('utf8').replace('<script type="module"', () => `${fixtureScript}${STUB}${fixture === 'conversation-states' ? conversationStatesBridge : fixture === 'conversation-complete' ? conversationCompleteBridge : ''}<script type="module"`))
|
||
if (fixture === 'typography') body = Buffer.from(body.toString('utf8').replace('</body>', () => `${typographyProbes}</body>`))
|
||
}
|
||
res.writeHead(200, { 'content-type': MIME[extname(filePath)] ?? 'application/octet-stream' })
|
||
res.end(body)
|
||
} catch {
|
||
res.writeHead(404).end('not found')
|
||
}
|
||
})
|
||
|
||
server.listen(port, '127.0.0.1', () => {
|
||
console.log(`verify-ui listening on http://127.0.0.1:${server.address().port}/`)
|
||
})
|