feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s

Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
+188
View File
@@ -0,0 +1,188 @@
"""Native Dify model menus. Run inside the Dify API image; no vendor patch.
stage APP_ID... --backup-dir PATH installs metadata and saves drafts, never
publishes. publish checks the staged hashes and publishes atomically. Backups
contain workflow configuration and must remain private on the server.
"""
import argparse
import copy
import json
from pathlib import Path
PROVIDER = '故事平台 · 分步骤模型'
TEXT = [('deepseek-v4-1-flash-260910', 'DeepSeek V4.1 Flash'),
('doubao-seed-evolving', 'Doubao Seed Evolving'),
('doubao-seed-2-1-pro-260915', 'Doubao Seed 2.1 Pro'),
('doubao-seed-2-1-lite-260915', 'Doubao Seed 2.1 Lite')]
BOOK = TEXT[1:]
IMAGE = [('doubao-seedream-5-0-pro-260628', 'Seedream 5.0 Pro'),
('doubao-seedream-5-0-flash-260915', 'Seedream 5.0 Flash'),
('doubao-seedream-4-0-20260415', 'Seedream 4.0')]
STEPS = {'classify': ('分类与故事拆分', TEXT, TEXT[0][0]),
'daily': ('审核今天实际事件', TEXT, TEXT[0][0]),
'write-book': ('全部故事统一编写整本', BOOK, BOOK[0][0]),
'illustrate': ('绘制该章情景插画', IMAGE, IMAGE[0][0])}
def i18n(text):
return {'en_US': text, 'zh_Hans': text}
def schema():
paths = {}
for step, (title, options, default) in STEPS.items():
props = {n: {'type': 'string', 'description': d} for n, d in
[('context_id', '后台任务编号'), ('step_token', '后台临时执行凭证'),
('model', '模型'), ('custom_model', '自定义火山模型 ID 或 ep- 接入点') ]}
props['model'].update(default=default, enum=[x[0] for x in options]+['__custom__'])
props['custom_model']['default'] = ''
if step != 'illustrate': props['prompt'] = {'type': 'string', 'description': '提示词'}
else: props['chapter_index'] = {'type': 'integer', 'description': '章节序号'}
paths['/steps/'+step] = {'post': {
'operationId': step.replace('-', '_'), 'summary': title,
'requestBody': {'required': True, 'content': {'application/json': {'schema': {
'type': 'object', 'properties': props, 'required': ['context_id', 'step_token', 'model']}}}},
'responses': {'200': {'description': '执行状态、实际模型和统计,不含长者原文'}}}}
return {'openapi': '3.0.3', 'info': {'title': PROVIDER, 'version': '1.0.0',
'description': '在各节点选择火山模型。草稿测试使用虚构示例;发布后用于后续线上任务。'},
'servers': [{'url': 'http://model-bridge:8188'}], 'paths': paths}
def convert_graph(graph, provider_id):
graph = copy.deepcopy(graph)
changed = set()
for n in graph['nodes']:
d = n['data']; step = n['id']
if step not in STEPS: continue
if d['type'] == 'tool':
if d.get('provider_id') != provider_id: raise ValueError('node already uses another provider')
continue
if d['type'] != 'http-request' or d['url'] != 'http://model-bridge:8188/steps/'+step:
raise ValueError('unexpected model node; refusing to replace')
body = d['body']['data'][0]['value']
# Iteration index is a native numeric variable, not a JSON literal.
old = json.loads(body.replace('{{#pictures.item#}}', '0'))
title, _, default = STEPS[step]
# Dify's string editor persists mixed templates. A variable binding on
# a string looks empty in 1.17 and is erased when the model is changed.
params = {v: {'type': 'mixed', 'value': '{{#prepared.'+v+'#}}'} for v in ['context_id', 'step_token']}
if step == 'illustrate': params['chapter_index'] = {'type': 'variable', 'value': ['pictures', 'item']}
else: params['prompt'] = {'type': 'mixed', 'value': old.get('prompt', '')}
inherit = {k: v for k, v in d.items() if k in ['isInIteration', 'isInLoop', 'iteration_id', 'selected']}
n['data'] = dict(type='tool', title=title, desc='选择模型后先测试,再发布;当前运行任务保留原版本。',
provider_type='api', provider_id=provider_id, provider_name=PROVIDER,
tool_name=step.replace('-', '_'), tool_label=title, tool_node_version='2',
tool_configurations={'model': {'type': 'constant', 'value': default},
'custom_model': {'type': 'mixed', 'value': ''}},
tool_parameters=params, **inherit)
changed.add(step)
for n in graph['nodes']:
d = n['data']
if d['type'] != 'code': continue
variables = d.get('variables', [])
if any(v.get('value_selector', [None])[0] in changed for v in variables):
d['variables'] = [v for v in variables if v['variable'] != 'status_code']
for v in d['variables']:
if v['value_selector'][0] in changed and v['value_selector'][1] == 'body':
v['value_selector'][1] = 'text'
d['code'] = d['code'].replace('body: str, status_code: int', 'body: str')
d['code'] = '\n'.join(l for l in d['code'].split('\n') if 'if status_code != 200:' not in l)
types = {n['id']: n['data']['type'] for n in graph['nodes']}
for e in graph['edges']:
e['data'].update(sourceType=types[e['source']], targetType=types[e['target']])
return graph
def install_provider(account, tenant, session):
from core.tools.entities.tool_entities import ApiProviderSchemaType, ToolParameter
from core.plugin.entities.parameters import PluginParameterOption
from core.tools.entities.common_entities import I18nObject
from models.tools import ApiToolProvider
from services.tools.api_tools_manage_service import ApiToolManageService
from sqlalchemy import select
p = session.scalar(select(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant, ApiToolProvider.name == PROVIDER))
if p is None:
ApiToolManageService.create_api_tool_provider(user_id=account.id, tenant_id=tenant,
provider_name=PROVIDER, icon={'type': 'emoji', 'value': '🧠', 'background': '#EFF4FF'},
credentials={'auth_type': 'none'}, schema_type=ApiProviderSchemaType.OPENAPI,
schema=json.dumps(schema(), ensure_ascii=False), privacy_policy='', custom_disclaimer='', labels=[])
p = session.scalar(select(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant, ApiToolProvider.name == PROVIDER))
# Dify 1.17's OpenAPI parser discards enum/form. Enrich its native serialized
# bundles so the supported SELECT UI and runtime read the same metadata.
bundles, _ = ApiToolManageService.convert_schema_to_tool_bundles(json.dumps(schema(), ensure_ascii=False), {})
for b in bundles:
_, options, default = STEPS[b.operation_id.replace('_', '-')]
for param in b.parameters:
if param.name == 'model':
param.type = ToolParameter.ToolParameterType.SELECT
param.form = ToolParameter.ToolParameterForm.FORM
param.label = I18nObject(**i18n('模型'))
param.default = default
param.options = [PluginParameterOption(value=v, label=i18n(label)) for v, label in options]+[
PluginParameterOption(value='__custom__', label=i18n('自定义模型 / 接入点'))]
elif param.name == 'custom_model':
param.form = ToolParameter.ToolParameterForm.FORM
param.label = I18nObject(**i18n('自定义模型 ID(选择自定义时填写)'))
param.default = ''
else:
param.form = ToolParameter.ToolParameterForm.LLM
p.schema = json.dumps(schema(), ensure_ascii=False)
p.tools_str = json.dumps([b.model_dump(mode='json') for b in bundles], ensure_ascii=False)
session.commit()
return p.id
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('mode', choices=['stage', 'publish', 'rollback'])
parser.add_argument('apps', nargs='+')
parser.add_argument('--backup-dir', required=True)
args = parser.parse_args()
from app_factory import create_app
from extensions.ext_database import db
from models import App, Workflow
from models.account import Account
from services.workflow_service import WorkflowService
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
_, flask = create_app()
dest = Path(args.backup_dir); dest.mkdir(parents=True, exist_ok=True)
dest.chmod(0o700)
with flask.app_context(), sessionmaker(db.engine, expire_on_commit=False)() as session:
apps = [session.get(App, id) for id in args.apps]
assert all(apps) and len({a.tenant_id for a in apps}) == 1
account = session.get(Account, apps[0].created_by)
service = WorkflowService()
if args.mode == 'stage':
provider = install_provider(account, apps[0].tenant_id, session)
receipts = []
for a in apps:
draft = session.scalar(select(Workflow).where(Workflow.app_id == a.id, Workflow.version == 'draft'))
target = dest/(a.id+'.json')
if args.mode == 'stage':
if target.exists(): raise ValueError('backup exists; use a new backup directory')
published = session.get(Workflow, a.workflow_id)
backup = {'app_id': a.id, 'published_id': a.workflow_id, 'draft_graph': draft.graph_dict,
'published_graph': published.graph_dict, 'original_hash': draft.unique_hash}
graph = convert_graph(draft.graph_dict, provider)
service.sync_draft_workflow(app_model=a, graph=graph, features=draft.features_dict,
unique_hash=draft.unique_hash, account=account, environment_variables=[], conversation_variables=[],
session=session, graph_only=True, commit=False)
session.flush()
backup['staged_hash'] = draft.unique_hash
target.write_text(json.dumps(backup, ensure_ascii=False, indent=2)); target.chmod(0o600)
receipts.append({'app_id': a.id, 'provider_id': provider, 'staged_hash': draft.unique_hash})
elif args.mode == 'publish':
backup = json.loads(target.read_text())
if a.workflow_id != backup['published_id'] or draft.unique_hash != backup['staged_hash']:
raise ValueError('workflow changed since staging; aborting publish')
published = service.publish_workflow(session=session, app_model=a, account=account,
marked_name='各步骤模型可配置', marked_comment='保留原模型和提示词;模型在节点菜单中选择。')
a.workflow_id = published.id
receipts.append({'app_id': a.id, 'workflow_id': published.id})
else:
backup = json.loads(target.read_text())
service.sync_draft_workflow(app_model=a, graph=backup['draft_graph'], features=draft.features_dict,
unique_hash=draft.unique_hash, account=account, environment_variables=[], conversation_variables=[],
session=session, graph_only=True, commit=False)
a.workflow_id = backup['published_id']
receipts.append({'app_id': a.id, 'restored': True})
session.commit()
print(json.dumps({'mode': args.mode, 'workflows': receipts}, ensure_ascii=False))
if __name__ == '__main__': main()