diff --git a/mcpapps/app.html b/mcpapps/app.html index 7548859..206fd79 100644 --- a/mcpapps/app.html +++ b/mcpapps/app.html @@ -36,11 +36,56 @@ const pendingRequests=new Map(); const systemTheme=typeof window.matchMedia==="function"?window.matchMedia("(prefers-color-scheme: dark)"):null; let hostTheme=""; + let lastData=null; + const messages={ + en:{ + waiting:"Waiting for tool output…",item:"Item",step:"Step",status:"STATUS",moreItems:"{count} more", + noRecallMatches:"No Recall matches.",noMatches:"No matches",recallEntry:"Recall entry",recallCard:"Recall card", + noChanges:"No changes",previewOnly:"Preview only",written:"Written",completed:"Completed",recallUpdated:"Recall updated", + items:"{count} items",workflowMatch:"Workflow match",score:"score {score}",candidates:"{count} candidates",candidate:"Candidate",noWorkflowMatches:"No workflow matches.", + result:"Result",content:"Content",mcpTool:"MCP tool",externalMcp:"external MCP",server:"Server",tool:"Tool",contentCount:"{count} content",externalMcpVia:"External MCP via {server}",externalMcpInvoked:"External MCP tool invoked", + artifact:"Artifact",file:"file",temporaryLink:"temporary link",type:"Type",size:"Size",dimensions:"Dimensions",artifactId:"Artifact ID",expires:"Expires",signedUrl:"Signed URL",published:"published",updated:"Updated",state:"State",statusLabel:"Status",path:"Path", + agentDockSkills:"AgentDock Skills",commonSkills:"Common Skills",workflow:"Workflow",recall:"Recall",capabilities:"Capabilities",devices:"Devices",online:"Online",on:"ON",off:"OFF", + unavailable:"Unavailable",contextUnavailable:"Context unavailable",nodeOffline:"AgentDock node is offline",nodeUnavailable:"{name} is currently unavailable", + tasks:"{count} tasks",noTasks:"No tasks.",steps:"{done} / {total} steps",review:"review {status}",sessions:"{count} sessions",noAcpSessions:"No ACP sessions.",session:"Session",authenticated:"authenticated",deleted:"deleted",noMessages:"No user or assistant messages in this AgentDock process.", + dryRun:"dry run",noChange:"no change",previewTruncated:"preview truncated",noChangeSentence:"No change.",fileChange:"File change",unableInitialize:"Unable to initialize MCP App.", + action_search:"SEARCH",action_write:"WRITE",action_match:"MATCH",action_call:"CALL",action_publish:"PUBLISH",action_context:"CONTEXT",action_list:"LIST",action_task:"TASK",action_change:"CHANGE",action_move:"MOVE",action_status:"STATUS", + state_pending:"pending",state_in_progress:"in progress",state_completed:"completed",state_blocked:"blocked",state_active:"active",state_running:"running",state_success:"success",state_error:"error",state_failed:"failed",state_online:"online",state_offline:"offline",state_unavailable:"unavailable",state_enabled:"enabled",state_disabled:"disabled",state_ready:"ready",state_configured:"configured",state_exited:"exited",state_killed:"killed",state_timeout:"timeout",state_live:"live" + }, + "zh-CN":{ + waiting:"等待工具输出…",item:"项目",step:"步骤",status:"状态",moreItems:"还有 {count} 项", + noRecallMatches:"没有匹配的 Recall 内容。",noMatches:"没有匹配结果",recallEntry:"Recall 条目",recallCard:"Recall 卡片", + noChanges:"无变更",previewOnly:"仅预览",written:"已写入",completed:"已完成",recallUpdated:"Recall 已更新", + items:"{count} 项",workflowMatch:"工作流匹配",score:"分数 {score}",candidates:"{count} 个候选",candidate:"候选项",noWorkflowMatches:"没有匹配的工作流。", + result:"结果",content:"内容",mcpTool:"MCP 工具",externalMcp:"外部 MCP",server:"服务",tool:"工具",contentCount:"{count} 项内容",externalMcpVia:"通过 {server} 调用外部 MCP",externalMcpInvoked:"已调用外部 MCP 工具", + artifact:"产物",file:"文件",temporaryLink:"临时链接",type:"类型",size:"大小",dimensions:"尺寸",artifactId:"产物 ID",expires:"过期时间",signedUrl:"签名 URL",published:"已发布",updated:"更新时间",state:"状态",statusLabel:"状态",path:"路径", + agentDockSkills:"AgentDock Skills",commonSkills:"Common Skills",workflow:"工作流",recall:"Recall",capabilities:"能力",devices:"设备",online:"在线",on:"开启",off:"关闭", + unavailable:"不可用",contextUnavailable:"上下文暂不可用",nodeOffline:"AgentDock 节点当前离线",nodeUnavailable:"{name} 当前不可用", + tasks:"{count} 个任务",noTasks:"暂无任务。",steps:"{done} / {total} 步",review:"评审 {status}",sessions:"{count} 个会话",noAcpSessions:"暂无 ACP 会话。",session:"会话",authenticated:"已认证",deleted:"已删除",noMessages:"当前 AgentDock 进程中没有用户或助手消息。", + dryRun:"试运行",noChange:"无变更",previewTruncated:"预览已截断",noChangeSentence:"无变更。",fileChange:"文件变更",unableInitialize:"无法初始化 MCP App。", + action_search:"搜索",action_write:"写入",action_match:"匹配",action_call:"调用",action_publish:"发布",action_context:"上下文",action_list:"列表",action_task:"任务",action_change:"变更",action_move:"移动",action_status:"状态", + state_pending:"待处理",state_in_progress:"进行中",state_completed:"已完成",state_blocked:"已阻塞",state_active:"活动",state_running:"运行中",state_success:"成功",state_error:"错误",state_failed:"失败",state_online:"在线",state_offline:"离线",state_unavailable:"不可用",state_enabled:"已启用",state_disabled:"已停用",state_ready:"就绪",state_configured:"已配置",state_exited:"已退出",state_killed:"已终止",state_timeout:"超时",state_live:"活动" + } + }; + const matchLocale=value=>{ + const candidate=String(value||"").trim().toLowerCase(); + if(candidate==="en"||candidate.startsWith("en-"))return "en"; + if(candidate==="zh"||candidate==="zh-cn"||candidate==="zh-sg"||candidate==="zh-hans"||candidate.startsWith("zh-hans-"))return "zh-CN"; + return ""; + }; + const resolveLocale=values=>{for(const value of values){const matched=matchLocale(value);if(matched)return matched}return "en"}; + let locale=resolveLocale([...(navigator.languages||[]),navigator.language]); + const messageFor=key=>messages[locale][key]??messages.en[key]; + const t=(key,vars={})=>{let text=messageFor(key)??key;for(const [name,value] of Object.entries(vars))text=text.replaceAll("{"+name+"}",String(value));return text}; + const stateLabel=value=>{const raw=String(value||"");return messageFor("state_"+raw)??raw.replaceAll("_"," ")}; + const actionLabel=value=>{const raw=String(value||"");return messageFor("action_"+raw)??raw.replaceAll("_"," ").toUpperCase()}; + const applyLocale=value=>{const next=resolveLocale([value]);if(locale===next)return;locale=next;document.documentElement.lang=locale;if(lastData){lastSerialized="";render(lastData)}}; + document.documentElement.lang=locale; const el=(tag,className,text)=>{const node=document.createElement(tag);if(className)node.className=className;if(text!==undefined)node.textContent=String(text);return node}; const isObject=value=>value!==null&&typeof value==="object"&&!Array.isArray(value); const scalar=value=>value===null||["string","number","boolean"].includes(typeof value); - const actionLabel=value=>String(value||"").replaceAll("_"," ").toUpperCase(); + root.textContent=t("waiting"); function preferredTheme(){return systemTheme&&systemTheme.matches?"dark":"light"} function applyTheme(theme){ @@ -58,6 +103,7 @@ if(Object.prototype.hasOwnProperty.call(context,"theme")){ hostTheme=context.theme==="light"||context.theme==="dark"?context.theme:""; } + if(Object.prototype.hasOwnProperty.call(context,"locale")||Object.prototype.hasOwnProperty.call(context,"language"))applyLocale(context.locale||context.language); if(isObject(context.styles))applyHostVariables(context.styles.variables); applyTheme(hostTheme||preferredTheme()); } @@ -129,7 +175,7 @@ const statusClass=status==="in_progress"?"in-progress":status; if(status==="completed"||status==="in_progress"||status==="blocked")reached=index; const node=el("span","progress-node "+statusClass); - node.title=(step.title||step.id||"Step")+" · "+status; + node.title=(step.title||step.id||t("step"))+" · "+stateLabel(status); progress.append(node);index++; } if(!index)return null; @@ -142,7 +188,7 @@ function headline(fragment,action,primary,secondary){ const line=el("div","headline"); - line.append(el("strong","action",actionLabel(action)||"STATUS")); + line.append(el("strong","action",actionLabel(action)||t("status"))); if(primary)line.append(el("span","path",primary)); if(secondary){line.append(el("span","arrow","→"));line.append(el("span","path",secondary))} fragment.append(line); @@ -177,7 +223,7 @@ function contextItems(items){ if(!Array.isArray(items))return []; - return items.filter(isObject).map(item=>({name:String(item.name||"Item"),description:String(item.description||"")})); + return items.filter(isObject).map(item=>({name:String(item.name||t("item")),description:String(item.description||"")})); } function contextPill(value,label,className){ @@ -202,11 +248,11 @@ const list=el("div","context-list"); for(const item of items.slice(0,limit)){ const row=el("div","context-item"); - row.append(el("div","context-name",item.name||"Item")); + row.append(el("div","context-name",item.name||t("item"))); if(item.description)row.append(el("div","context-desc",item.description)); list.append(row); } - if(items.length>limit)list.append(el("div","context-more","还有 "+(items.length-limit)+" 项")); + if(items.length>limit)list.append(el("div","context-more",t("moreItems",{count:items.length-limit}))); section.append(list); } parent.append(section); @@ -225,7 +271,7 @@ for(const item of items){ if(!isObject(item))continue; const row=el("div","result-item"); - row.append(el("div","result-name",item.title||item.name||item.id||item.path||"Item")); + row.append(el("div","result-name",item.title||item.name||item.id||item.path||t("item"))); const side=el("div","result-side"); if(item.dotStatus){ const state=String(item.dotStatus).toLowerCase(); @@ -294,16 +340,16 @@ function recallTitle(data){ if(data.query)return data.query; if(data.path)return data.path.split("/").filter(Boolean).pop()||data.path; - if(isObject(data.recall))return data.recall.title||data.recall.path||"Recall entry"; - if(isObject(data.card))return data.card.title||data.card.path||"Recall card"; + if(isObject(data.recall))return data.recall.title||data.recall.path||t("recallEntry"); + if(isObject(data.card))return data.card.title||data.card.path||t("recallCard"); return "Recall"; } function recallWriteState(data){ - if(data.dry_run===true)return data.changed===false?"No changes":"Preview only"; - if(data.written===true)return "Written"; - if(data.changed===false)return "No changes"; - return "Completed"; + if(data.dry_run===true)return data.changed===false?t("noChanges"):t("previewOnly"); + if(data.written===true)return t("written"); + if(data.changed===false)return t("noChanges"); + return t("completed"); } function renderRecall(data){ @@ -311,7 +357,7 @@ const title=recallTitle(data); if(Array.isArray(data.results)){ const count=Number(data.count??data.results.length); - const meta=[data.recall_kind||"",count+" items"].filter(Boolean).join(" · "); + const meta=[data.recall_kind||"",t("items",{count})].filter(Boolean).join(" · "); headline(fragment,"search",title); metaLine(fragment,[meta]); resultList(fragment,data.results.map(item=>isObject(item)?{ @@ -319,9 +365,9 @@ badge:item.frontmatter&&item.frontmatter.type?item.frontmatter.type:"", description:item.snippet||"", path:item.path||"" - }:item),"No Recall matches."); + }:item),t("noRecallMatches")); const first=data.results.find(isObject); - const preview=first?first.snippet||first.title||first.path||"":"No matches"; + const preview=first?first.snippet||first.title||first.path||"":t("noMatches"); const footer=compactResultFooter(preview,meta); compactShell({action:"search",title,className:"result-toggle"},footer?[footer]:[],fragment); return; @@ -337,46 +383,46 @@ const card=isObject(data.card)?data.card:null; if(recall){ metricStrip(fragment,[ - {label:"Type",value:recall.frontmatter&&recall.frontmatter.type||target}, - {label:"Updated",value:recall.frontmatter&&recall.frontmatter.updated_at||""}, - {label:"State",value:state} + {label:t("type"),value:recall.frontmatter&&recall.frontmatter.type||target}, + {label:t("updated"),value:recall.frontmatter&&recall.frontmatter.updated_at||""}, + {label:t("state"),value:state} ]); const body=recall.content||recall.body||recall.summary; if(body)fragment.append(el("div","recall-body",body)); }else if(card){ metricStrip(fragment,[ - {label:"Type",value:card.card_type||card.type||target}, - {label:"Status",value:card.status||""}, - {label:"State",value:state} + {label:t("type"),value:card.card_type||card.type||target}, + {label:t("statusLabel"),value:stateLabel(card.status||"")}, + {label:t("state"),value:state} ]); if(card.content||card.body)fragment.append(el("div","recall-body",card.content||card.body)); } - detailFields(fragment,[{label:"Path",value:data.path||recall&&recall.path||card&&card.path,mono:true}]); + detailFields(fragment,[{label:t("path"),value:data.path||recall&&recall.path||card&&card.path,mono:true}]); if(Array.isArray(data.warnings)&&data.warnings.length)fragment.append(el("div","result-note",data.warnings.join(" · "))); if(typeof data.diff==="string"&&data.diff)appendDiffPreview(fragment,data.diff); const compactRows=[]; if(data.path)compactRows.push(el("span","compact-path",data.path)); - const footer=compactResultFooter(state==="Written"?"Recall updated":state,[target,action].filter(Boolean).join(" · ")); + const footer=compactResultFooter(data.written===true?t("recallUpdated"):state,[target,actionLabel(action)].filter(Boolean).join(" · ")); if(footer)compactRows.push(footer); compactShell({action,title,className:"result-toggle"},compactRows,fragment); } function workflowMatchTitle(data){ - if(Array.isArray(data.candidates)){const first=data.candidates.find(isObject);if(first)return first.title||first.id||"Workflow match"} - return "Workflow match"; + if(Array.isArray(data.candidates)){const first=data.candidates.find(isObject);if(first)return first.title||first.id||t("workflowMatch")} + return t("workflowMatch"); } function renderWorkflow(data){ const fragment=document.createDocumentFragment(); const title=workflowMatchTitle(data); - const meta=[data.recommended||"",data.best_candidate_score!==undefined?"score "+data.best_candidate_score:"",data.count!==undefined?data.count+" candidates":""].filter(Boolean).join(" · "); + const meta=[data.recommended||"",data.best_candidate_score!==undefined?t("score",{score:data.best_candidate_score}):"",data.count!==undefined?t("candidates",{count:data.count}):""].filter(Boolean).join(" · "); headline(fragment,"match",title); metaLine(fragment,[meta]); if(data.recommendation_reason)fragment.append(el("p","summary",data.recommendation_reason)); resultList(fragment,Array.isArray(data.candidates)?data.candidates.map(item=>isObject(item)?{ - title:item.title||item.id||"Candidate",score:item.score!==undefined?String(item.score):"",description:item.reason||item.description||"",path:item.id||"" - }:item):[],"No workflow matches."); + title:item.title||item.id||t("candidate"),score:item.score!==undefined?String(item.score):"",description:item.reason||item.description||"",path:item.id||"" + }:item):[],t("noWorkflowMatches")); let preview=data.recommendation_reason||""; if(!preview&&Array.isArray(data.candidates)){const first=data.candidates.find(isObject);if(first)preview=first.reason||first.description||first.title||first.id||""} const footer=compactResultFooter(preview||title,meta); @@ -387,14 +433,14 @@ if(!isObject(result))return; const content=Array.isArray(result.content)?result.content:[]; const text=content.filter(isObject).filter(item=>item.type==="text"&&typeof item.text==="string").map(item=>item.text.trim()).filter(Boolean).join("\n\n"); - if(text)appendTextOutput(fragment,"Result",text); + if(text)appendTextOutput(fragment,t("result"),text); if(!text&&result.structuredContent!==undefined&&result.structuredContent!==null){ let serialized=""; try{serialized=JSON.stringify(result.structuredContent,null,2)}catch{serialized=String(result.structuredContent)} - appendTextOutput(fragment,"Result",serialized); + appendTextOutput(fragment,t("result"),serialized); } const attachments=content.filter(isObject).filter(item=>item.type!=="text").map(item=>({ - title:item.name||item.uri||item.mimeType||item.type||"Content", + title:item.name||item.uri||item.mimeType||item.type||t("content"), badge:item.type||"",description:item.mimeType||"" })); if(attachments.length)resultList(fragment,attachments,""); @@ -402,38 +448,38 @@ function renderDynamicMCP(data){ const fragment=document.createDocumentFragment(); - const title=data.name||"MCP tool"; + const title=data.name||t("mcpTool"); const callResult=isObject(data.result)?data.result:{}; const content=Array.isArray(callResult.content)?callResult.content:[]; - const status=callResult.isError===true?"error":"success"; + const status=callResult.isError===true?stateLabel("error"):stateLabel("success"); const separator=title.indexOf(":"); const server=separator>0?title.slice(0,separator):""; const tool=separator>0?title.slice(separator+1):title; headline(fragment,"call",title); - metaLine(fragment,["external MCP",status]); - detailFields(fragment,[{label:"Server",value:server,mono:true},{label:"Tool",value:tool,mono:true}]); + metaLine(fragment,[t("externalMcp"),status]); + detailFields(fragment,[{label:t("server"),value:server,mono:true},{label:t("tool"),value:tool,mono:true}]); appendMCPCallResult(fragment,callResult); - const meta=[status,content.length?content.length+" content":""].filter(Boolean).join(" · "); - const footer=compactResultFooter(server?"External MCP via "+server:"External MCP tool invoked",meta); + const meta=[status,content.length?t("contentCount",{count:content.length}):""].filter(Boolean).join(" · "); + const footer=compactResultFooter(server?t("externalMcpVia",{server}):t("externalMcpInvoked"),meta); compactShell({action:"call",title,className:"result-toggle"},footer?[footer]:[],fragment); } function renderArtifact(data){ const fragment=document.createDocumentFragment(); - const title=data.filename||data.artifact_id||"Artifact"; - const type=data.archive===true?"tar.gz":data.mime_type||"file"; + const title=data.filename||data.artifact_id||t("artifact"); + const type=data.archive===true?"tar.gz":data.mime_type||t("file"); headline(fragment,"publish",title); - metaLine(fragment,[data.expires_at?"temporary link":"",data.artifact_id||""]); + metaLine(fragment,[data.expires_at?t("temporaryLink"):"",data.artifact_id||""]); metricStrip(fragment,[ - {label:"Type",value:type},{label:"Size",value:humanBytes(data.size_bytes)},{label:"Dimensions",value:data.width&&data.height?data.width+" × "+data.height:""} + {label:t("type"),value:type},{label:t("size"),value:humanBytes(data.size_bytes)},{label:t("dimensions"),value:data.width&&data.height?data.width+" × "+data.height:""} ]); detailFields(fragment,[ - {label:"Artifact ID",value:data.artifact_id,mono:true},{label:"Expires",value:data.expires_at},{label:"SHA-256",value:data.sha256,mono:true},{label:"Signed URL",value:data.url,href:data.url,mono:true} + {label:t("artifactId"),value:data.artifact_id,mono:true},{label:t("expires"),value:data.expires_at},{label:"SHA-256",value:data.sha256,mono:true},{label:t("signedUrl"),value:data.url,href:data.url,mono:true} ]); const compactRows=[]; const meta=[type,humanBytes(data.size_bytes),data.width&&data.height?data.width+"×"+data.height:""].filter(Boolean).join(" · "); if(meta)compactRows.push(el("span","compact-meta-row",meta)); - const footer=compactResultFooter(data.expires_at?"temporary link":"published",data.artifact_id||""); + const footer=compactResultFooter(data.expires_at?t("temporaryLink"):t("published"),data.artifact_id||""); if(footer)compactRows.push(footer); compactShell({action:"publish",title,className:"result-toggle"},compactRows,fragment); } @@ -448,18 +494,18 @@ const recall=isObject(data.recall)&&data.recall.enabled; const overview=el("div","context-overview"); - appendContextOverview(overview,skills.length,"AgentDock Skills"); + appendContextOverview(overview,skills.length,t("agentDockSkills")); appendContextOverview(overview,mcps.length,"MCP"); - appendContextOverview(overview,workflows.length,"Workflow"); - appendContextOverview(overview,recall?"ON":"OFF","Recall"); + appendContextOverview(overview,workflows.length,t("workflow")); + appendContextOverview(overview,recall?t("on"):t("off"),t("recall")); parent.append(overview); const groups=el("div","context-groups"); - appendContextSection(groups,"AgentDock Skills",skills,10); - appendContextSection(groups,"Common Skills",commonSkills,10); + appendContextSection(groups,t("agentDockSkills"),skills,10); + appendContextSection(groups,t("commonSkills"),commonSkills,10); appendContextSection(groups,"MCP",mcps,8); appendContextSection(groups,"ACP",acp,4); - appendContextSection(groups,"Workflow",workflows,8); + appendContextSection(groups,t("workflow"),workflows,8); parent.append(groups); return{skills,mcps,workflows,recall}; } @@ -468,8 +514,8 @@ const fragment=document.createDocumentFragment(); const summary=appendNodeAgentContext(fragment,data); const compact=el("span","compact-context"); - compact.append(contextPill(summary.skills.length,"AgentDock Skills"),contextPill(summary.mcps.length,"MCP"),contextPill(summary.workflows.length,"Workflow","workflow"),contextPill(summary.recall?"ON":"OFF","Recall")); - compactShell({action:"context",title:"Capabilities"},[compact],fragment); + compact.append(contextPill(summary.skills.length,t("agentDockSkills")),contextPill(summary.mcps.length,"MCP"),contextPill(summary.workflows.length,t("workflow"),"workflow"),contextPill(summary.recall?t("on"):t("off"),t("recall"))); + compactShell({action:"context",title:t("capabilities")},[compact],fragment); } function renderFleetAgentContext(data){ @@ -491,8 +537,8 @@ content.replaceChildren(); if(!isObject(node.context)||node.error){ const unavailable=el("div","context-node-unavailable"); - unavailable.append(el("strong","",String(node.name||node.node_id||"AgentDock")+" 当前不可用")); - unavailable.append(el("span","",String(node.error||(node.online===true?"Context 暂不可用":"AgentDock 节点当前离线")))); + unavailable.append(el("strong","",t("nodeUnavailable",{name:String(node.name||node.node_id||"AgentDock")}))); + unavailable.append(el("span","",String(node.error||(node.online===true?t("contextUnavailable"):t("nodeOffline"))))); content.append(unavailable); requestAnimationFrame(reportSize); return; @@ -515,22 +561,22 @@ const button=el("button","context-node-tab"); button.type="button"; button.setAttribute("aria-pressed","false"); - const status=node.error?"不可用":(node.online===true?"在线":"离线"); + const status=node.error?t("unavailable"):(node.online===true?t("online"):stateLabel("offline")); button.append(el("span","context-node-tab-name",node.name||node.node_id||"AgentDock")); button.append(el("span","context-node-tab-status",status)); button.addEventListener("click",()=>selectNode(index)); buttons.push(button);tabs.append(button); } const fleetOverview=el("div","context-overview fleet-overview"); - appendContextOverview(fleetOverview,nodes.length,"Devices"); - appendContextOverview(fleetOverview,online,"Online"); + appendContextOverview(fleetOverview,nodes.length,t("devices")); + appendContextOverview(fleetOverview,online,t("online")); fragment.append(tabs,fleetOverview,content); const workflowCount=contextItems(shared.workflow_templates).length; const recallEnabled=isObject(shared.recall)&&shared.recall.enabled; const compact=el("span","compact-context"); - compact.append(contextPill(nodes.length,"Devices"),contextPill(online,"Online"),contextPill(workflowCount,"Workflow"),contextPill(recallEnabled?"ON":"OFF","Recall")); - compactShell({action:"context",title:"Capabilities"},[compact],fragment); + compact.append(contextPill(nodes.length,t("devices")),contextPill(online,t("online")),contextPill(workflowCount,t("workflow")),contextPill(recallEnabled?t("on"):t("off"),t("recall"))); + compactShell({action:"context",title:t("capabilities")},[compact],fragment); const initialIndex=Math.max(0,nodes.findIndex(node=>node.online===true&&isObject(node.context)&&!node.error)); if(nodes.length)selectNode(initialIndex); } @@ -544,34 +590,34 @@ const fragment=document.createDocumentFragment(); if(Array.isArray(data.tasks)){ const count=Number(data.count??data.tasks.length); - headline(fragment,data.action||"list",String(count)+" tasks"); + headline(fragment,data.action||"list",t("tasks",{count})); const rows=el("div","rows"); for(const task of data.tasks){ if(!isObject(task))continue; const row=el("div","row"); - row.append(el("div","row-title",task.title||task.id||"Task")); - row.append(el("div","row-status",task.status||"")); + row.append(el("div","row-title",task.title||task.id||t("action_task"))); + row.append(el("div","row-status",stateLabel(task.status||""))); const current=isObject(task.current_step)?task.current_step:null; if(current)row.append(el("div","row-sub",current.title||current.id||"")); rows.append(row); } - if(rows.childNodes.length)fragment.append(rows);else fragment.append(el("div","empty","No tasks.")); + if(rows.childNodes.length)fragment.append(rows);else fragment.append(el("div","empty",t("noTasks"))); const first=data.tasks.find(task=>isObject(task)); const current=first&&isObject(first.current_step)?first.current_step:null; const listMeta=current?(current.title||current.id||""):first?(first.status||""):""; const listRow=listMeta?el("span","compact-summary",listMeta):null; - compactShell({action:data.action||"list",title:String(count)+" tasks"},[listRow],fragment);return; + compactShell({action:data.action||"list",title:t("tasks",{count})},[listRow],fragment);return; } const task=taskRecord(data); - const title=task.title||task.id||data.task_id||"Task"; + const title=task.title||task.id||data.task_id||t("action_task"); const completed=Number(task.completed_step_count||0),total=Number(task.step_count||0); let steps=Array.isArray(task.steps)?task.steps:[]; if(!steps.length&&isObject(task.current_step))steps=[task.current_step]; headline(fragment,data.action||"task",title); - metaLine(fragment,[task.status||task.phase||data.review_status,total>0?completed+" / "+total+" steps":"",data.final_review&&data.final_review.status?"review "+data.final_review.status:""]); + metaLine(fragment,[stateLabel(task.status||task.phase||data.review_status),total>0?t("steps",{done:completed,total}):"",data.final_review&&data.final_review.status?t("review",{status:stateLabel(data.final_review.status)}):""]); if(typeof task.summary==="string"&&task.summary)fragment.append(el("p","summary",task.summary)); - if(steps.length){const list=el("div","steps");for(const step of steps){if(!isObject(step))continue;const status=String(step.status||"pending");const statusClass=status==="in_progress"?"in-progress":status;const row=el("div","step "+statusClass);const marker=el("div","step-marker",status==="completed"?"✓":status==="in_progress"?"●":status==="blocked"?"!":"");row.append(marker);row.append(el("div","step-title",step.title||step.id||"Step"));row.append(el("div","step-status",status));list.append(row)}fragment.append(list)} + if(steps.length){const list=el("div","steps");for(const step of steps){if(!isObject(step))continue;const status=String(step.status||"pending");const statusClass=status==="in_progress"?"in-progress":status;const row=el("div","step "+statusClass);const marker=el("div","step-marker",status==="completed"?"✓":status==="in_progress"?"●":status==="blocked"?"!":"");row.append(marker);row.append(el("div","step-title",step.title||step.id||t("step")));row.append(el("div","step-status",stateLabel(status)));list.append(row)}fragment.append(list)} const activeStep=steps.find(step=>isObject(step)&&(step.status==="in_progress"||step.status==="blocked"))||(isObject(task.current_step)?task.current_step:null); const compactRows=[]; if(typeof task.summary==="string"&&task.summary)compactRows.push(el("span","compact-summary",task.summary)); @@ -579,11 +625,11 @@ if(progress){ const progressRow=el("span","compact-progress-row"); progressRow.append(progress); - const progressMeta=[activeStep?(activeStep.title||activeStep.id||""):"",total>0?completed+" / "+total:task.status||task.phase||""].filter(Boolean).join(" · "); + const progressMeta=[activeStep?(activeStep.title||activeStep.id||""):"",total>0?t("steps",{done:completed,total}):stateLabel(task.status||task.phase||"")].filter(Boolean).join(" · "); if(progressMeta)progressRow.append(el("span","compact-progress-meta",progressMeta)); compactRows.push(progressRow); }else if(activeStep||total>0){ - compactRows.push(el("span","compact-meta-row",[activeStep?(activeStep.title||activeStep.id||""):"",total>0?completed+" / "+total:""].filter(Boolean).join(" · "))); + compactRows.push(el("span","compact-meta-row",[activeStep?(activeStep.title||activeStep.id||""):"",total>0?t("steps",{done:completed,total}):""].filter(Boolean).join(" · "))); } compactShell({action:data.action||"task",title},compactRows,fragment); } @@ -595,21 +641,21 @@ const insertions=Number.isFinite(Number(data.insertions))?"+"+Number(data.insertions):""; const deletions=Number.isFinite(Number(data.deletions))?"−"+Number(data.deletions):""; const detailMeta=el("div","meta"); - for(const value of [data.dry_run===true?"dry run":"",data.changed===false?"no change":"",data.truncated===true?"preview truncated":""].filter(Boolean))detailMeta.append(el("span","",value)); + for(const value of [data.dry_run===true?t("dryRun"):"",data.changed===false?t("noChange"):"",data.truncated===true?t("previewTruncated"):""].filter(Boolean))detailMeta.append(el("span","",value)); if(insertions)detailMeta.append(el("span","compact-file-stat add",insertions)); if(deletions)detailMeta.append(el("span","compact-file-stat del",deletions)); if(detailMeta.childNodes.length)fragment.append(detailMeta); if(typeof data.diff_preview==="string"&&data.diff_preview)appendDiffPreview(fragment,data.diff_preview); - else if(data.changed===false)fragment.append(el("div","empty","No change.")); + else if(data.changed===false)fragment.append(el("div","empty",t("noChangeSentence"))); const normalizedPrimary=String(primary||"").replaceAll("\\","/"); - const fileName=normalizedPrimary.split("/").filter(Boolean).pop()||"File change"; + const fileName=normalizedPrimary.split("/").filter(Boolean).pop()||t("fileChange"); const compactRows=[]; if(primary){ const pathText=data.action==="move"&&data.new_path?primary+" → "+data.new_path:primary; compactRows.push(el("span","compact-path",pathText)); } const fileRow=el("span","compact-file-row"); - const fileState=[data.dry_run===true?"dry run":"",data.changed===false?"no change":"",data.truncated===true?"preview truncated":""].filter(Boolean).join(" · "); + const fileState=[data.dry_run===true?t("dryRun"):"",data.changed===false?t("noChange"):"",data.truncated===true?t("previewTruncated"):""].filter(Boolean).join(" · "); fileRow.append(el("span","compact-file-state",fileState)); const stats=el("span","compact-file-stats"); if(insertions)stats.append(el("span","compact-file-stat add",insertions)); @@ -629,25 +675,25 @@ const state=isObject(data.state)?data.state:data; const fragment=document.createDocumentFragment(); if(Array.isArray(state.sessions)){ - headline(fragment,state.action||"list",String(state.count??state.sessions.length)+" sessions"); + headline(fragment,state.action||"list",t("sessions",{count:String(state.count??state.sessions.length)})); const rows=el("div","rows"); for(const session of state.sessions){ if(!isObject(session))continue; const row=el("div","row"); - row.append(el("div","row-title",session.id||session.session_id||"Session")); - row.append(el("div","row-status",session.status||"")); + row.append(el("div","row-title",session.id||session.session_id||t("session"))); + row.append(el("div","row-status",stateLabel(session.status||""))); if(session.cwd)row.append(el("div","row-sub",session.cwd)); rows.append(row); } - if(rows.childNodes.length)fragment.append(rows);else fragment.append(el("div","empty","No ACP sessions.")); + if(rows.childNodes.length)fragment.append(rows);else fragment.append(el("div","empty",t("noAcpSessions"))); const first=state.sessions.find(session=>isObject(session)); const listMeta=first?[first.status||"",first.agent||"",first.cwd||""].filter(Boolean).join(" · "):""; - compactShell({action:state.action||"list",title:String(state.count??state.sessions.length)+" sessions"},[listMeta?el("span","compact-summary",listMeta):null],fragment);return; + compactShell({action:state.action||"list",title:t("sessions",{count:String(state.count??state.sessions.length)})},[listMeta?el("span","compact-summary",listMeta):null],fragment);return; } const session=isObject(state.session)?state.session:{}; const identity=acpSessionID(state)||session.agent||(isObject(state.agent)?state.agent.name||state.agent.title:"")||"ACP"; headline(fragment,state.action||"status",identity); - metaLine(fragment,[session.status||state.status,state.authenticated===true?"authenticated":"",session.agent||"",session.mode_id||state.mode_id||"",state.deleted===true?"deleted":""]); + metaLine(fragment,[stateLabel(session.status||state.status),state.authenticated===true?t("authenticated"):"",session.agent||"",session.mode_id||state.mode_id||"",state.deleted===true?t("deleted"):""]); if(session.cwd)fragment.append(el("div","path",session.cwd)); if(Array.isArray(state.messages)){ const messages=el("div","messages"); @@ -658,7 +704,7 @@ item.append(el("div","message-text",message.content)); messages.append(item); } - if(messages.childNodes.length)fragment.append(messages);else fragment.append(el("div","empty","No user or assistant messages in this AgentDock process.")); + if(messages.childNodes.length)fragment.append(messages);else fragment.append(el("div","empty",t("noMessages"))); const compactRows=[]; const latest=[...state.messages].reverse().find(message=>isObject(message)&&(message.role==="user"||message.role==="assistant")&&typeof message.content==="string"&&message.content); if(latest)compactRows.push(el("span","compact-summary",latest.content)); @@ -692,7 +738,7 @@ function render(data){ if(!renderable(data))return; - const serialized=JSON.stringify(data);if(serialized===lastSerialized)return;lastSerialized=serialized; + lastData=data;const serialized=locale+"|"+JSON.stringify(data);if(serialized===lastSerialized)return;lastSerialized=serialized; if(expectedView==="agentdock_context")renderAgentContext(data); else if(expectedView==="task_progress")renderTask(data); else if(expectedView==="file_change")renderFileChange(data); @@ -738,7 +784,7 @@ rpcNotify("ui/notifications/initialized",{}); startAutoResize(); }catch(_){ - root.textContent="Unable to initialize MCP App."; + root.textContent=t("unableInitialize"); } } diff --git a/mcpapps/apps_test.go b/mcpapps/apps_test.go index 6cb893f..dbf732a 100644 --- a/mcpapps/apps_test.go +++ b/mcpapps/apps_test.go @@ -49,3 +49,45 @@ func TestHTMLUsesStandardHostContextThemeAndSemanticTokens(t *testing.T) { t.Fatal("shared MCP App still forces a light-only color scheme") } } + +func TestHTMLLocalizesFromBrowserAndHostLocale(t *testing.T) { + html := HTML("agentdock_context", "Context") + for _, marker := range []string{ + `"zh-CN":{`, + `resolveLocale([...(navigator.languages||[]),navigator.language])`, + `candidate==="zh-cn"`, + `candidate==="zh-hans"`, + `const applyLocale=value=>{const next=resolveLocale([value])`, + `context.locale||context.language`, + `document.documentElement.lang=locale`, + `message.method==="ui/notifications/host-context-changed"`, + `applyHostContext(message.params)`, + `t("nodeUnavailable"`, + `t("contextUnavailable")`, + `t("nodeOffline")`, + `t("moreItems"`, + `compactShell({action:"context",title:t("capabilities")}`, + } { + if !strings.Contains(html, marker) { + t.Fatalf("shared MCP App missing locale marker %q", marker) + } + } + + // 这些文案曾直接写在渲染逻辑里,必须只存在于语言字典,避免再次出现中英混排。 + if strings.Contains(html, `candidate.startsWith("zh-")`) { + t.Fatal("shared MCP App must not coerce every Chinese locale, including Traditional Chinese, to zh-CN") + } + + for _, direct := range []string{ + `+" 当前不可用"`, + `?"不可用":`, + `?"在线":"离线"`, + `"还有 "+(items.length-limit)+" 项"`, + `title:"Capabilities"`, + `label:"State"`, + } { + if strings.Contains(html, direct) { + t.Fatalf("shared MCP App still contains hard-coded locale text %q", direct) + } + } +}